Metadata-Version: 2.5
Name: slabterminator
Version: 0.7.0
Summary: Enumerate the symmetrically unique slab terminations of a bulk crystal for a given Miller index from crystal symmetry, and generate slabs.
Project-URL: Homepage, https://github.com/d2r2group/slabterminator
Project-URL: Repository, https://github.com/d2r2group/slabterminator
Author-email: Peter Schindler <p.schindler@northeastern.edu>
License-Expression: MIT
License-File: LICENSE.md
Keywords: Miller index,crystallography,materials science,pymatgen,slab,surface science,surface termination,symmetry
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: Topic :: Scientific/Engineering :: Physics
Requires-Python: >=3.12
Requires-Dist: numpy>=1.25.0
Requires-Dist: pymatgen>=2026.5.4
Description-Content-Type: text/markdown

<div align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="logo.svg">
    <img alt="SlabTerminator Logo" src="logo-light.svg" width="360">
  </picture><br>
</div>

# SlabTerminator

![Python - Version](https://img.shields.io/pypi/pyversions/slabterminator)
[![PyPI - Version](https://img.shields.io/pypi/v/slabterminator?color=blue)](https://pypi.org/project/slabterminator)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Enumerate the **symmetrically unique slab terminations** of a bulk crystal for a
given Miller index from crystal symmetry.

Given a bulk `pymatgen` `Structure` and a Miller index, `SlabTerminator` finds every
distinct way the crystal can be cleaved along that plane, tells you which
terminations are polar vs. nonpolar, and (optionally) builds the ready-to-use slab
structures with vacuum.

### Why not just use pymatgen?

pymatgen's `SlabGenerator.get_slabs()` enumerates candidate cleaves by clustering
atoms along the normal within a tolerance, builds a full slab for each, and then
deduplicates those slabs by comparing them pairwise with `StructureMatcher` (a
tolerance-based lattice-reduction + site-matching comparison). `SlabTerminator` instead
works purely from group theory: it projects the oriented cell's space-group operations
onto the surface normal and groups candidate cleaves into symmetry orbits *before* any
vacuum is added (see [How it works](#how-it-works)), so it never builds a slab just to
decide uniqueness and never runs a pairwise structure comparison. Two practical
consequences:

- **It is much faster.** For plain slab generation (`symmetrize=False`; **left plot**),
  on our benchmark of 30,302 (material, Miller-index) cases across 1000 materials, `SlabTerminator` is, 
  per case, roughly **15× (index 1) to 92× (index 3) faster** than `SlabGenerator` 
  when building slabs, a **geometric-mean ~62× across the full dataset** (median ~75×; 
  the tail-weighted whole-workload aggregate is ~97×). And **~108× faster** when only
  counting terminations, because it skips both the per-cleave slab construction and the
  pairwise `StructureMatcher` deduplication. All per-case figures here are geometric means
  of the per-case ratios. In `batch` mode there is a further speedup on top, as the bulk
  symmetry is computed once per material and reused across all its Miller indices:
  **1.67× aggregate wall-clock** over 17 materials (a whole-workload figure, not a per-case
  geometric mean — per material the median is 1.48×, up to 2.83×).

  When the slabs are being **symmetrized** (`symmetrize=True`), the **per-case**
  generation speedup over pymatgen is even more pronounced: geometric-mean roughly **19×
  (index 1) to 197× (index 3)** per case (**~113× across the full dataset**; the tail-weighted
  aggregate is ~274×). This per-case figure is
  *higher* than the `symmetrize=False` speedup because pymatgen's atom-removal symmetrizer has
  pathological worst cases (its slowest polar level-3 orientations run into seconds apiece),
  whereas `SlabTerminator` trims straight to a verified crystal flip center in closed form.
  pymatgen additionally trims each polar slab atom-by-atom and re-runs its symmetry analysis
  (detailed speedup breakdown, see **right plot**).

  Both benchmarks are run with [`scripts/benchmark/benchmark.py`](scripts/benchmark/benchmark.py) and the full 
  list of mpids used for benchmarking is stored in `benchmark_HPC_<date>.meta.md`.

  <div align="center"><img src="results/benchmark_combined_speedup_vs_size_2026-08-18.png" width="100%" alt="Speedup of SlabTerminator over pymatgen SlabGenerator for symmetrize=False (left) and symmetrize=True (right), as a function of problem size and Miller index, with y-axes matched across the two"></div>

  *Per-case speedup (pymatgen `SlabGenerator` ÷ `SlabTerminator`) vs. problem size and
  Miller index, up to max index 3. Left: plain generation (`symmetrize=False`), right:
  symmetrized generation (`symmetrize=True`); all 30,302 cases completed for both tools
  (300 s timeout, none excluded). The top scatter shows every case; the two bar panels show
  the **geometric-mean** speedup per bin with multiplicative geometric-standard-deviation
  whiskers. The two columns share matched y-axes per panel, so the higher `symmetrize=True` 
  speedups are directly readable against the left. Both compare against `SlabTerminator` 
  at a single fixed layer tolerance (`tol` = pymatgen `ftol` = 0.1, no auto scan); 
  matched geometry (≥ 8 Å slab, 10 Å vacuum, `max_normal_search=1`, centered), best of 
  3 runs (best of 2 for pymatgen), on an Intel Xeon Gold 6448Y HPC node (all methods 
  timed interleaved per case, so the relative speedup is unaffected by any drift) /
  pymatgen 2026.5.4. "Cleavage planes" = candidate interlayer cleaves before symmetry
  reduction. Per-case data in date-stamped `results/benchmark_<date>.csv` and
  `results/benchmark_symmetrize_<date>.csv`.*

  This holds up end-to-end, too. The same 1000-material benchmark set, generated to a Miller
  index of 3 in full `batch` mode (10 workers on Northeastern University's Explorer HPC
  Cluster, build + serialization + JSONL I/O), took **67.5 s** with `SlabTerminator` versus 
  **10121.4 s** with pymatgen for `symmetrize=False` (72k slabs each), 
  a **~150× whole-batch speedup** on real production hardware at a matched fixed layer 
  tolerance (`tol` = `ftol` = 0.1). With `symmetrize=True` the same build took **102.8 s** versus 
  **11446.1 s** (**~111×**), even though `SlabTerminator` emits (and serializes to disk) 
  **~1.66× more slabs** than pymatgen there (143k vs. 86k), because it keeps the polar 
  parents and unsymmetrizable Tasker-III terminations
  pymatgen drops (`SlabTerminator` can reproduce pymatgen's behavior of dropping every polar
  slab by passing `symmetrize="drop_all_polar"` instead of `symmetrize=True`, keeping only the
  slabs it can make symmetric). The two SLURM entry points used are [`scripts/hpc/build_slab_dataset_slurm.py`](scripts/hpc/build_slab_dataset_slurm.py)
  and [`scripts/hpc/build_slab_dataset_slurm_pymatgen.py`](scripts/hpc/build_slab_dataset_slurm_pymatgen.py),
  matched on everything but the generator (run-wide details in
  [`results/benchmark_HPC_<date>.meta.md`](results/benchmark_HPC_2026-08-18.meta.md)).

- **That speed makes it more robust, by making tolerance sweeps cheap.** Both methods
  share a layer-grouping tolerance (`SlabTerminator`'s `tol`, pymatgen's `ftol`) that
  changes the count: too tight over-splits near-coplanar atoms, too loose merges
  distinct terminations. Any single tolerance is a guess. Because `SlabTerminator`'s
  analysis reuses one cached oriented cell and its symmetry operations (no slab
  rebuilds, no extra spglib calls), sweeping a whole grid of tolerances is nearly free,
  so it can report the count as a function of tolerance and pick a stable plateau
  automatically (`scan_termination_stability()` / `tol="auto"`; the selection rules are in
  `slabterminator._tol_scan.TolScanner.select_plateau`). Running the same
  sweep through `get_slabs` means rebuilding every slab and re-running the pairwise
  comparison at each tolerance, expensive enough that in practice one picks a single
  `ftol` and trusts it.

- **Its polarity verdict is cell-orientation-invariant.** To decide whether a slab is
  nonpolar, `SlabTerminator` applies the bulk flipping operations directly to the built
  slab's atoms and tests self-coincidence in the surface `{a, b}` lattice, so the answer
  depends only on the surface, not on the choice of `max_normal_search`. Running the full
  `SpacegroupAnalyzer` on the slab (pymatgen's route) instead misses a genuine flip when
  that choice leaves the cell's c vector oblique, making the verdict `max_normal_search`-
  dependent: at the library defaults, `4mm(1,1,0)`, `3m(1,1,0)` and `-6m2(1,1,0)` read
  polar at the cheap cell but nonpolar at any `max_normal_search`. `SlabTerminator`
  reports the same verdict in every one of those cells.
  Because that verdict is also thickness-invariant, `SlabTerminator` checks it on the
  minimal single-cell oriented cell rather than the full slab, so for a 15 Å-thick slab
  it is **~5× faster per termination** than the full-slab `SpacegroupAnalyzer` call
  pymatgen must run.

- **A full surface-polarity classification is built in.** Because the orbits already come
  from the crystal's projected symmetry operations, that same machinery classifies each
  orientation's polarity at no extra cost: whether a flipping operation exists at all
  (`has_flip_op_by_bulk`, equivalently whether any nonpolar slab is achievable at all --
  its negation is the truly-polar Tasker III verdict), whether a *point* flip survives
  adding vacuum (`has_symmorphic_flip_op_by_bulk`), and the full
  crystallographic surface-polarity class of Hinuma et al. (`hinuma_polarity_type`:
  `polar` / `nonpolar_A` / `nonpolar_B` / `nonpolar_C`, mapping onto the three Tasker
  ionic-surface types). pymatgen returns only a per-slab `is_symmetric()` bit and no
  orientation-level polarity classification (the four classes are documented on
  `slabterminator.datatypes.SlabProperties`).

## How it works

The oriented unit cell is periodic along the surface normal. `SlabTerminator` runs
two complementary symmetry analyses, one on each side of adding vacuum:

- **Without vacuum → which cleaves are the same slab.** The cell's space-group
  operations are projected onto the 1D coordinate along the surface normal as
  `g → ±g + τ` (`g` is a cleave's position along the normal, `+`/`−` keeps/flips the normal
  direction, and `τ` is the operation's translation along the normal). Candidate interlayer
  gaps are grouped into orbits under these maps;
  each orbit is one unique termination. Screw axes, glide planes, and pure
  c-translations (which only exist while the cell is periodic along the normal) are
  what relate cleaves recurring at different heights, so this must be done *before*
  vacuum is added.
- **With vacuum → is a slab polar.** Each built slab-with-vacuum is checked for a
  surviving operation that maps the normal to its negative. If one exists the two
  faces are equivalent (**nonpolar**); otherwise the slab is **polar**. This must be
  done *with* vacuum, since the periodic cell can otherwise report a false symmetry
  through a glide/screw whose translation the vacuum breaks.

### Terminology: termination vs. face

Throughout `SlabTerminator`, a **termination** means one symmetrically distinct
**cleave** (a distinct way to cut the crystal along the Miller plane), and there is
exactly one termination (one as-cut slab) per orbit of candidate cleavage gaps.
`get_unique_terminations()`, `n_unique_terminations`, and `termination_id` all count and
label cleaves in this sense.

A single cleave produces a slab with two **faces** (top and bottom). When the crystal has
a flipping operation relating them the slab is **nonpolar** and both faces are equivalent;
when it does not, the slab is **polar** and its two faces are inequivalent surface
terminations in the surface-science sense. So one termination (one cleave) can expose two
distinct faces, which is exactly why a polar slab yields *two* symmetrized children (one
per face) and why `include_flipped=True` emits the opposite-face counterpart of each polar
slab. To keep the vocabulary unambiguous, "termination" in this codebase always denotes the
cleave, never a single face; the face notion appears only through `include_flipped` and the
`flipped` field.

## Benchmarking script details

This method is both faster and more robust than the fingerprint-based enumeration of an
older, unpublished version of this code (2022).
[`scripts/benchmark/benchmark.py`](scripts/benchmark/benchmark.py) is the correctness +
speed comparison against that `UniqueSlabsGenerator` and against pymatgen's
`SlabGenerator` (`StructureMatcher`-based): one interleaved run co-measures both datasets,
so their timings share a single thermal state, and stores every per-case count and timing
in the date-stamped `results/benchmark_<date>.csv` (plain generation) and
`results/benchmark_symmetrize_<date>.csv` (symmetrized), one row per case keyed by its
mpid, each CSV beside a `.meta.json` sidecar recording the input dataset, the machine and
the `slabterminator` / `pymatgen` / `python` versions it ran against.
[`scripts/benchmark/benchmark_analysis.py`](scripts/benchmark/benchmark_analysis.py) reads
those CSVs, plots the figures above
(`results/benchmark_speedup_vs_size_<date>.png`, its symmetrize twin and the combined
figure) and writes `results/benchmark_summary_<date>.md` and
`results/benchmark_symmetrize_summary_<date>.md`, each stamped with the analyzed run's
date. Each script documents its own flags, in its module docstring and its `--help`:
`benchmark.py --reuse` pulls whole method **groups** (`new`/`old`/`pmg` and
`new_sym`/`auto_sym`/`pmg_sym`) from the existing CSVs instead of re-measuring them, and
`benchmark_analysis.py --date` analyzes a run other than the most recent on disk.

## A note on AI-assisted development

The original (unpublished) version of this software was written in 2021/22 and used a
fingerprint approach based on nearest-neighbor analysis to distinguish terminations.
With the help of Claude Code, an entirely new approach was developed from group theory
and symmetry operations, and I worked extensively back and forth with AI to ensure its
fidelity against both pymatgen and the older fingerprint method, as well as to optimize
and analyze the resulting speedup.

## Installation

Requires Python ≥ 3.12. The package is on [PyPI](https://pypi.org/project/slabterminator):

```bash
# with uv
uv add slabterminator

# with pip
pip install slabterminator
```

The runtime dependencies are `pymatgen` and `numpy`.

To work on the source instead, clone the repo and use [`uv`](https://docs.astral.sh/uv/)
to install it with its dev dependencies:

```bash
git clone https://github.com/d2r2group/slabterminator
cd slabterminator
uv sync
```

## Quick start

```python
from pymatgen.core import Structure
from slabterminator.core import SlabTerminator

structure = Structure.from_file("tests/test-cifs/Fe3C_mp-13154_conventional_standard.cif")

# Analyze the (1, 0, 1) surface.
gen = SlabTerminator(structure, (1, 0, 1))

# Cheap: just enumerate the unique terminations (no slabs built).
for term in gen.get_unique_terminations():
    print(term)
# Termination(gap_index=7, gap_position=0.0894..., multiplicity=2,
#             symmetric_by_bulk=False, termination_key='Fe1@0.42|Fe1;Fe1@0.53|Fe1')
# ... 4 terminations for Fe3C(101)

# Full: build one slab per unique termination, with vacuum.
result = gen.get_unique_slabs(
    vacuum_size=15.0,          # Angstrom of vacuum along c
    min_slab_thickness=10.0,   # grow the slab until it exceeds this thickness (Angstrom)
    max_normal_search=1,       # search for a more orthogonal output cell
)

print(result.properties.n_unique_terminations)  # 4
print(round(result.properties.surface_area, 2))  # 27.56

for i, entry in enumerate(result.slabs):
    print(i, round(entry.shift, 4),
          "polar" if not entry.is_symmetric_with_vacuum else "nonpolar",
          entry.top_layer_composition, "/", entry.bottom_layer_composition)
    entry.slab.to(filename=f"Fe3C_101_{i}.cif")   # entry.slab is a pymatgen Structure
```

Output:

```
0 0.0447 polar Fe / Fe
1 0.125  polar Fe / Fe
2 0.1844 polar Fe / C
3 0.2292 polar C / Fe
```

## All surfaces of one material

To enumerate the distinct surfaces of a bulk material, use the
`SlabTerminator.for_miller_family` classmethod. It runs the bulk symmetry analysis
**once** and hands the resulting operations to each per-facet `SlabTerminator`, so a
whole family costs one symmetry analysis instead of one per Miller index — the same reuse the
batch [pipeline](#batch-generation-over-many-materials) does, in a
lightweight form for library/interactive use:

```python
from slabterminator.core import SlabTerminator

# One representative per distinct family up to |h|,|k|,|l| = 2 (e.g. (100), (110),
# (111), ... for cubic). Each yielded generator is ready to build slabs.
for miller, gen in SlabTerminator.for_miller_family(structure, max_miller_index=2):
    result = gen.get_unique_slabs(vacuum_size=15.0, min_slab_thickness=10.0)
    print(miller, result.properties.n_unique_terminations)
```

Construction only analyzes terminations; the vacuum/thickness/`symmetrize` knobs stay on
each `get_unique_slabs(...)` call. Other `SlabTerminator` keywords (`tol`, `slab_symprec`,
`output_format`, ...) pass through, and `symprec` reaches both the shared bulk analysis and
every generator, so set it here (`help(SlabTerminator.for_miller_family)` has the checks).

For finished slab records across many materials in parallel, use
[`slabterminator.pipeline` / `batch`](#batch-generation-over-many-materials) instead.

## API

`SlabTerminator` is the library's entry point: construct it for one `(structure, Miller
index)` pair, then ask it for terminations or for built slabs. This section is that surface
at one line per item; every argument, return field, diagnostics code and symmetry flag is
documented in full by the docstrings that ship with the package, so `help()` on the names
below is the reference — `SlabTerminator` and `regenerate_slabs` from `slabterminator.core`,
`SlabProperties`, `SlabSettings` and `SlabEntry` from `slabterminator.datatypes`. The
repository additionally carries
[`docs/REFERENCE.md`](https://github.com/d2r2group/slabterminator/blob/main/docs/REFERENCE.md),
which sets the same contract out as prose.

### `SlabTerminator(structure, miller_index, tol=0.1, symprec=0.1, slab_symprec=None, bulk_symmetry_ops=None, output_format="pmg")`

The symmetry analysis runs at construction, on the cheapest oriented cell, and is
independent of how output slabs are later built. Pass a **conventional cell**: both the
Miller index and the symmetry operations are read in the basis given, so a cell whose
lattice is not point-group invariant — an anisotropic supercell — silently loses operations
and emits duplicate terminations.

| argument | meaning |
|---|---|
| `structure` | the bulk `pymatgen` `Structure`, ordered (one species per site) and well-formed |
| `miller_index` | three integers, not all zero |
| `tol` (`0.1`) | layer c-tolerance in Å for grouping atoms into layers, or `"auto"` to pick it from a stability scan (see `scan_termination_stability()`) |
| `symprec` (`0.1`) | crystallographic tolerance on the bulk cell; drives flip-op detection, the Hinuma class and orbit reduction, and fixes the two cleave-matching tolerances `gen.orbit_tol` and `gen.sym_tol`, derived from it rather than settable |
| `slab_symprec` (`=symprec`) | coincidence tolerance in Å for the with-vacuum face-symmetry check, which is independent of `vacuum_size` and `max_normal_search`; must be the equal-or-coarser of the two |
| `bulk_symmetry_ops` | the bulk's Cartesian space-group operations, reused across one material's Miller indices — one symmetry analysis per material instead of one per index |
| `output_format` (`"pmg"`) | a pymatgen `Structure` per slab, or `"dict"` for an `as_dict()`-shaped dict built straight from the cleave arrays |

Every argument is rejected outside its domain rather than silently reinterpreted, and the
tolerance bounds are absolute rather than advisory: a negative `symprec`, or a non-finite
structure, reaches spglib and segfaults the interpreter uncatchably, and `tol=nan` would
collapse any structure to a single termination. A disordered (partially occupied) or
malformed structure raises `ValueError`
(`help(SlabTerminator)` gives the full domain, and why each bound is there).

**Methods**

- **`get_unique_terminations()`** → `list[Termination]`, one per unique termination, each
  with `gap_index`, `gap_position`, `multiplicity`, `symmetric_by_bulk` and
  `termination_key`. No slab structures are built; this is the fast path.
- **`get_unique_slabs(...)`** → `UniqueSlabsResult`, building one slab per unique
  termination. Options: `vacuum_size` (`10.0`), `slab_thickness_cells` /
  `min_slab_thickness` (a floor, not a target, enforced on the occupied atomic span),
  `include_flipped` (`False`), `center_slab` (`True`), `max_normal_search` (`None`),
  `force_orthogonal_cell` (`False`), `prebuilt_output_cell`, and `symmetrize` (`False`) —
  which additionally emits up to two nonpolar **symmetrized** slabs per polar termination,
  trimmed in closed form straight to a verified crystal flip center rather than one atom at
  a time, with `True`/`"add"`, `"drop_parents"` and `"drop_all_polar"` selecting which polar
  slabs are kept alongside the children
  (`help(SlabTerminator.get_unique_slabs)` documents every option in full).
- **`scan_termination_stability()`** → `TolScanResult`, the count-vs-tolerance curve and the
  plateau `tol="auto"` selects from it (`help()` on it, and on
  `slabterminator._tol_scan.TolScanner.select_plateau` for the selection rules).
- **`SlabTerminator.for_miller_family(structure, max_miller_index, ...)`** → an iterator of
  `(miller_index, generator)` pairs, one per distinct Miller index, off one shared bulk
  analysis (see [All surfaces of one material](#all-surfaces-of-one-material)).
- **`regenerate_slabs(shifts, vacuum_size, oriented_unit_cell, ...)`**, module-level,
  rebuilds slabs from stored result fields and skips the analysis entirely
  (`help(regenerate_slabs)`).

`UniqueSlabsResult` is a `NamedTuple` of `properties`, `settings`, `oriented_unit_cell` and
`slabs`:

- **`properties`** — `n_unique_terminations`, `surface_area`, `has_flip_op_by_bulk`,
  `has_symmorphic_flip_op_by_bulk`, `hinuma_polarity_type` and `diagnostics`. The area and
  the two flags are fixed at construction and no `get_unique_slabs` argument can move them;
  the count and the polarity class are reported at the accompanying `settings.tol`.
- **`settings`** — what the run actually used, so it can be reproduced by passing the values
  back: the resolved `tol`, the two symmetry knobs `symprec` / `slab_symprec`
  (constructor arguments, but they move the emitted slabs), and the
  thickness request three times over — the resolved cell count beside the two arguments as
  passed, since no two of the three determine the third.
- **`slabs`** — a list of `SlabEntry`: `shift`, `is_symmetric_with_vacuum`,
  `top_layer_composition`, `bottom_layer_composition`, `slab`, `nsites`, `thickness_A`,
  `flipped`, `has_symmetrized_children`, `symmetrized`, `window_top`, `off_stoichiometry`,
  `diagnostics` and `termination_id`, plus two content-based join keys: `termination_key`
  (per termination family, stable across runs and platforms at a fixed `tol`) and `slab_key`
  (per individual slab). Both carry caveats worth reading before joining datasets on them
  (`help(SlabEntry)` is the field reference, those caveats included).

A build records any provenance concern it hits as a closed set of five string codes,
durably on `SlabProperties.diagnostics` (orientation-level) and `SlabEntry.diagnostics`
(per-slab) as well as through a live `warnings.warn` at the origin — `tol_ambiguous`,
`degenerate_layering`, `flip_flag_disagreement`, `thin_image_vacuum` and `internal_vacuum`.
`help(SlabProperties)` documents the three orientation-level codes, and `help(SlabEntry)`
which two reach a slab: `flip_flag_disagreement`
(the cheap without-vacuum flip flag contradicted the authoritative with-vacuum check, which is
the one that decides — the emitted slab is correct) and `internal_vacuum` (the slab holds a
vacuum-sized empty band *between* its own two faces, so its `thickness_A` overstates how
much material it has). The durable
field is what a batch has: a warning raised inside a worker process never reaches
`run_batch`'s log. Every warning carries the `SlabTerminatorWarning` category, so it can be
filtered or escalated on its own.

`slabterminator.utils` holds the helpers the core uses, chiefly
`get_sym_distinct_miller_indices_and_symops(structure, max_index)`, which returns the Miller
indices worth analyzing **and** the bulk operations to thread through them, from one
symmetry analysis of the structure (its docstring is the contract).

## Symmetry flags

Each boolean symmetry tag is named for **how** it is determined: from **bulk ops** (algebra
on the crystal's projected symmetry operations, no slab built), from a **verified**
with-vacuum slab, or from bulk ops **+ stoichiometry** (a per-layer composition check).
*Orientation-level* flags belong to the `(crystal, Miller index)` pair and are mirrored onto
every `SlabRecord`; *termination-level* flags describe one termination (cleave) or its slab.

| Flag | Level | From | Meaning |
|---|---|---|---|
| `has_flip_op_by_bulk` | orientation | bulk ops | a flipping op exists (normal → −normal) — exactly the "a nonpolar slab is obtainable at all" verdict, so `False` is truly polar (Tasker III, unreconstructable) |
| `has_symmorphic_flip_op_by_bulk` | orientation | bulk ops | a *point* flip op exists — an exact certificate that a nonpolar termination or trimming exists *in the crystal* |
| `hinuma_polarity_type` | orientation | bulk ops + stoichiometry | the crystallographic surface-polarity class: `polar` / `nonpolar_A` / `nonpolar_B` / `nonpolar_C` |
| `symmetric_by_bulk` | termination | bulk ops | a bulk flip leaves the cut inside the cleave's own empty band, so the flip maps the atom set onto itself |
| `is_symmetric_with_vacuum` | termination | verified | the built slab's two faces are genuinely equivalent (nonpolar as-cut) |
| `has_symmetrized_children` | termination | verified | this polar parent produced verified symmetrized children (`symmetrize=True` only) |

The names are chosen so the implications read directly: the four other boolean flags each
imply `has_flip_op_by_bulk`, and the negations cascade the other way. One is not a filter for
another, though — **`symmetric_by_bulk` does not predict `is_symmetric_with_vacuum` in
either direction**, so filter a dataset on `is_symmetric_with_vacuum`, which is the verified
verdict. `help(SlabProperties)` documents the four polarity classes and their approximate
Tasker correspondence; the full implication set, and the meaningful gaps between the flags,
are in [`docs/REFERENCE.md`](https://github.com/d2r2group/slabterminator/blob/main/docs/REFERENCE.md#flags).

## Batch generation over many materials

`SlabTerminator` handles one `(structure, Miller index)` pair. Two higher-level modules
build on it for high-throughput datasets. `slabterminator.pipeline` takes one bulk material
and returns a `MaterialResult` holding a flat list of `SlabRecord`s across all its Miller
indices — enumerating the distinct indices itself, sharing one bulk symmetry analysis across
them, and reporting a bad material as `MaterialResult(ok=False, error=...)` rather than
raising, so a batch can keep going:

```python
from slabterminator.pipeline import build_slabs_for_material, SlabGenConfig

config = SlabGenConfig(
    max_miller_index=3,
    tol="auto",              # pick each surface's layer tolerance from its plateau
    min_slab_thickness=15.0,
    vacuum_size=15.0,
    center_slab=False,
)
result = build_slabs_for_material(structure, config, material_id="mp-13154")

print(result.ok, result.n_slabs)                 # True 95
for rec in result.records:
    print(rec.miller, rec.slab_id, rec.symmetrized or ("flipped" if rec.flipped else "as_cut"),
          "polar" if not rec.is_symmetric_with_vacuum else "nonpolar",
          rec.top_layer_composition, "/", rec.bottom_layer_composition)
    # rec.slab is the with-vacuum pymatgen Structure (an as_dict()-shaped dict under
    # SlabGenConfig(output_format="dict")); rec.regenerate() rebuilds the no-vacuum slab,
    # and rec.regenerate(vacuum_size=config.vacuum_size) reproduces rec.slab itself.
```

`slabterminator.batch` fans that out across worker processes and streams each finished
`MaterialResult` to a sink callback. It is scheduler- and output-agnostic: you provide the
`(id, structure)` stream and an `on_result` writer (CSV, database, in-memory list, …), and a
material whose worker overruns `max_material_seconds` or crashes is recorded as a failure
rather than stalling the run:

```python
from slabterminator.batch import run_batch

records = []
def sink(result):
    if result.ok:
        records.extend(result.records)

# The __main__ guard is required, not stylistic: run_batch starts worker processes, and
# under the "spawn" start method (the default on macOS and Windows) each worker re-imports
# this module. Without the guard it would re-execute run_batch on import, recursively.
if __name__ == "__main__":
    materials = [("mp-13154", struct_a), ("mp-2657", struct_b)]  # structures or as_dict() forms
    n = run_batch(materials, config, n_workers=4,
                  max_material_seconds=3 * 3600, on_result=sink)
```

The docstrings document the rest: `help(SlabGenConfig)` for the four fields that
deliberately default differently from the library, `help(SlabRecord)` for its fields,
`help(SlabRecord.from_row)` for the `to_row()` / `from_row()` round trip and its
`precise_float` caveat, `help(SlabRecord.regenerate)` for the overrides, and
`help(run_batch)` for `result_transform`'s two process placements, the run manifest
`config_path=` writes, and the failure channels. `SlabGenConfig` documents its three
pipeline-specific fields (`max_miller_index`, `max_normal_search_limit`, `angle_tol`) on
itself and defers the rest to the `SlabTerminator` / `get_unique_slabs` arguments they are
passed to.

## Testing

```bash
uv run pytest    # everything, serially

# what CI runs: the parallel arm, then the one wall-clock-sensitive test
TIMED=tests/test_batch.py::TestRunBatch::test_finished_worker_is_not_reclaimed_as_a_timeout
uv run pytest -n auto --dist worksteal --deselect $TIMED
uv run pytest $TIMED
```

The two-command form is the faster one. That single test is split out because it asserts the batch
supervisor against a wall-clock budget with only ~0.6 s of margin, which needs a machine that is
not oversubscribed; everything else — the rest of `tests/test_batch.py` included — is
parallel-safe under [`pytest-xdist`](https://pytest-xdist.readthedocs.io/).

CI also measures branch coverage of `src/` and gates it with `--fail-under`, a one-way ratchet
seeded one item below the measured total (at ~2,100 items, one uncovered statement is ~0.05 pp).
Which settings that number depends on is commented in `pyproject.toml`'s `[tool.coverage.run]`;
statements deliberately outside the denominator carry a `# pragma: no cover - <reason>` at the guard.

The suite is heavily parametrized across the bundled fixtures; `pytest --collect-only -q`
reports how many cases that is on the tree you have. The 41 bundled CIFs in
[`tests/test-cifs/`](tests/test-cifs/) are three groups:

- **The sweep set (34 structures).** One representative for each of the **32
  crystallographic point groups**, spanning all 7 crystal systems, so the symmetry handling
  is exercised across the full range of surface symmetries: 31 Materials Project
  conventional standard cells plus one synthetic polar structure
  (`synthetic_polar_Pna21.cif`) for the point group `mm2`. Two further named fixtures are
  included as regression cases: `Ce2NiGe3` (mp-1102475), a glide / rich-symmetry cell whose
  reversal-canonical lower bound over-counts, and `Tb5Ir3` (mp-1188649), a P-hexagonal
  metal whose (1,3,3) surface exercises the symmetrized-window `max_normal_search`
  invariance. Every structure in this set runs through the same parametrized sweeps.
- **Five surface-polarity reference cells** with *published* classifications, which back the
  `hinuma_polarity_type` tests against the paper's own results: MgO rocksalt ((100) type A,
  (111) type C), SrTiO₃ perovskite ((001) type C, the Hinuma-vs-Tasker exception), and the
  paper's three worked type-B examples BeSO₄, high-pressure AgI and FeSe₂. Wurtzite AlN
  ((0001) polar) reuses its point-group cell. These are deliberately kept out of the sweep
  set so the point-group survey stays exactly one structure per point group.
- **Two un-parametrized cells** loaded directly by individual regression tests: `TiNi`
  (mp-1179013), pulled for a count-mismatch investigation, and `PaAs` (mp-11106), a
  symmetrized-child `max_normal_search`-invariance case.

The higher-index sweep (every distinct Miller index up to 3, for every sweep fixture) is
validated without hand-curated counts: each case is checked against independent,
orbit-free bounds — a hard gap-count ceiling, a symmetry-gated lower floor, and shift-free
count brackets — backed by an independently re-derived projected-orbit oracle for the
screw / sub-period regime those bounds cannot pin.

## References

The `hinuma_polarity_type` surface-polarity classification (`polar` / `nonpolar_A` /
`nonpolar_B` / `nonpolar_C`) implements the crystallographic scheme of:

> Y. Hinuma, Y. Kumagai, F. Oba, I. Tanaka, "Categorization of surface polarity from a
> crystallographic approach", *Computational Materials Science* **113** (2016) 221–230.
> doi:[10.1016/j.commatsci.2015.11.042](https://doi.org/10.1016/j.commatsci.2015.11.042)

The A/B/C classes map (barring formal-charge exceptions) onto the three ionic-surface
types of:

> P. W. Tasker, "The stability of ionic crystal surfaces", *Journal of Physics C: Solid
> State Physics* **12** (1979) 4977–4984.
> doi:[10.1088/0022-3719/12/22/036](https://doi.org/10.1088/0022-3719/12/22/036)
