slabterminator 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. slabterminator-0.2.0/.claude/settings.local.json +16 -0
  2. slabterminator-0.2.0/.github/workflows/ci.yml +29 -0
  3. slabterminator-0.2.0/.gitignore +134 -0
  4. slabterminator-0.2.0/CITATION.cff +26 -0
  5. slabterminator-0.2.0/LICENSE.md +21 -0
  6. slabterminator-0.2.0/PKG-INFO +333 -0
  7. slabterminator-0.2.0/README.md +313 -0
  8. slabterminator-0.2.0/pyproject.toml +54 -0
  9. slabterminator-0.2.0/ruff.toml +40 -0
  10. slabterminator-0.2.0/scripts/UniqueSlabsGenerator.py +621 -0
  11. slabterminator-0.2.0/scripts/benchmark.csv +1353 -0
  12. slabterminator-0.2.0/scripts/benchmark.meta.json +40 -0
  13. slabterminator-0.2.0/scripts/benchmark.py +1020 -0
  14. slabterminator-0.2.0/scripts/benchmark_analysis.py +323 -0
  15. slabterminator-0.2.0/scripts/benchmark_speedup_vs_size.png +0 -0
  16. slabterminator-0.2.0/scripts/benchmark_summary.md +60 -0
  17. slabterminator-0.2.0/scripts/build_slab_dataset_slurm.py +158 -0
  18. slabterminator-0.2.0/scripts/pmg_fragility_index1.log +229 -0
  19. slabterminator-0.2.0/scripts/pmg_fragility_sweep.py +264 -0
  20. slabterminator-0.2.0/scripts/pmg_mismatches_summary.md +267 -0
  21. slabterminator-0.2.0/scripts/pmg_timing_profile.py +378 -0
  22. slabterminator-0.2.0/scripts/pmg_timing_profile_Fe3C_101.log +60 -0
  23. slabterminator-0.2.0/src/slabterminator/__init__.py +7 -0
  24. slabterminator-0.2.0/src/slabterminator/batch.py +224 -0
  25. slabterminator-0.2.0/src/slabterminator/core.py +1120 -0
  26. slabterminator-0.2.0/src/slabterminator/pipeline.py +263 -0
  27. slabterminator-0.2.0/src/slabterminator/utils.py +474 -0
  28. slabterminator-0.2.0/tests/conftest.py +342 -0
  29. slabterminator-0.2.0/tests/test-cifs/AgClO4_mp-22993_conventional_standard.cif +38 -0
  30. slabterminator-0.2.0/tests/test-cifs/AlN_mp-661_conventional_standard.cif +30 -0
  31. slabterminator-0.2.0/tests/test-cifs/BAsO4_mp-3277_conventional_standard.cif +38 -0
  32. slabterminator-0.2.0/tests/test-cifs/BBr3_mp-23225_conventional_standard.cif +34 -0
  33. slabterminator-0.2.0/tests/test-cifs/BP_mp-1479_conventional_standard.cif +34 -0
  34. slabterminator-0.2.0/tests/test-cifs/Bi_mp-23152_conventional_standard.cif +32 -0
  35. slabterminator-0.2.0/tests/test-cifs/Ca2B5Os3_mp-542455_conventional_standard.cif +46 -0
  36. slabterminator-0.2.0/tests/test-cifs/CaF2_mp-2741_conventional_standard.cif +38 -0
  37. slabterminator-0.2.0/tests/test-cifs/CdAs2_mp-471_conventional_standard.cif +38 -0
  38. slabterminator-0.2.0/tests/test-cifs/Cd_mp-94_conventional_standard.cif +28 -0
  39. slabterminator-0.2.0/tests/test-cifs/CeCoC2_mp-20753_conventional_standard.cif +42 -0
  40. slabterminator-0.2.0/tests/test-cifs/Cr12P7_mp-505626_conventional_standard.cif +45 -0
  41. slabterminator-0.2.0/tests/test-cifs/CrAu4_mp-1070789_conventional_standard.cif +36 -0
  42. slabterminator-0.2.0/tests/test-cifs/Fe3C_mp-13154_conventional_standard.cif +34 -0
  43. slabterminator-0.2.0/tests/test-cifs/FeO_mp-1279742_conventional_standard.cif +34 -0
  44. slabterminator-0.2.0/tests/test-cifs/FeS2_mp-226_conventional_standard.cif +38 -0
  45. slabterminator-0.2.0/tests/test-cifs/FeSiP4_mp-9198_conventional_standard.cif +35 -0
  46. slabterminator-0.2.0/tests/test-cifs/FeSi_mp-871_conventional_standard.cif +34 -0
  47. slabterminator-0.2.0/tests/test-cifs/GeTe_mp-938_conventional_standard.cif +32 -0
  48. slabterminator-0.2.0/tests/test-cifs/MoC_mp-2305_conventional_standard.cif +28 -0
  49. slabterminator-0.2.0/tests/test-cifs/NbAs_mp-2059_conventional_standard.cif +34 -0
  50. slabterminator-0.2.0/tests/test-cifs/Ni3TeO6_mp-19448_conventional_standard.cif +56 -0
  51. slabterminator-0.2.0/tests/test-cifs/Ni8Bi8SI_mp-1105564_conventional_standard.cif +44 -0
  52. slabterminator-0.2.0/tests/test-cifs/PI2_mp-29443_conventional_standard.cif +32 -0
  53. slabterminator-0.2.0/tests/test-cifs/PI3_mp-27529_conventional_standard.cif +34 -0
  54. slabterminator-0.2.0/tests/test-cifs/SrSi2_mp-496_conventional_standard.cif +38 -0
  55. slabterminator-0.2.0/tests/test-cifs/Ta2H_mp-24082_conventional_standard.cif +32 -0
  56. slabterminator-0.2.0/tests/test-cifs/Te_mp-19_conventional_standard.cif +29 -0
  57. slabterminator-0.2.0/tests/test-cifs/TiO2_mp-2657_conventional_standard.cif +32 -0
  58. slabterminator-0.2.0/tests/test-cifs/U_mp-44_conventional_standard.cif +30 -0
  59. slabterminator-0.2.0/tests/test-cifs/WCl6_mp-571518_conventional_standard.cif +47 -0
  60. slabterminator-0.2.0/tests/test-cifs/synthetic_polar_Pna21.cif +34 -0
  61. slabterminator-0.2.0/tests/test_batch.py +120 -0
  62. slabterminator-0.2.0/tests/test_pipeline.py +193 -0
  63. slabterminator-0.2.0/tests/test_slab_terminator.py +1544 -0
  64. slabterminator-0.2.0/tests/test_utils.py +465 -0
  65. slabterminator-0.2.0/uv.lock +1452 -0
@@ -0,0 +1,16 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run python -c ' *)",
5
+ "Bash(uv run *)",
6
+ "Bash(python3 -)",
7
+ "Bash(python -c \"import ast; ast.parse\\(open\\('tests/test_slab_terminator.py'\\).read\\(\\)\\); ast.parse\\(open\\('scripts/UniqueSlabsGenerator.py'\\).read\\(\\)\\); ast.parse\\(open\\('src/slabterminator/core.py'\\).read\\(\\)\\); print\\('OK'\\)\")",
8
+ "Bash(python3 -c \"import ast; ast.parse\\(open\\('tests/test_slab_terminator.py'\\).read\\(\\)\\); ast.parse\\(open\\('scripts/UniqueSlabsGenerator.py'\\).read\\(\\)\\); ast.parse\\(open\\('src/slabterminator/core.py'\\).read\\(\\)\\); print\\('OK'\\)\")",
9
+ "Bash(cat > *)",
10
+ "Bash(python3 -c ' *)"
11
+ ],
12
+ "additionalDirectories": [
13
+ "/tmp"
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v5
14
+ with:
15
+ fetch-depth: 0 # full history + tags so hatch-vcs can derive the version
16
+
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v7
19
+ with:
20
+ enable-cache: true
21
+
22
+ - name: Install Python and dependencies
23
+ run: uv sync
24
+
25
+ - name: Lint (ruff)
26
+ run: uv run ruff check src tests scripts
27
+
28
+ - name: Run tests
29
+ run: uv run pytest
@@ -0,0 +1,134 @@
1
+ #Misc
2
+ /*.cif
3
+ .idea
4
+ _archive
5
+ .DS_Store
6
+
7
+ # Byte-compiled / optimized / DLL files
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+
12
+ # C extensions
13
+ *.so
14
+
15
+ # Distribution / packaging
16
+ .Python
17
+ build/
18
+ develop-eggs/
19
+ dist/
20
+ downloads/
21
+ eggs/
22
+ .eggs/
23
+ lib/
24
+ lib64/
25
+ parts/
26
+ sdist/
27
+ var/
28
+ wheels/
29
+ pip-wheel-metadata/
30
+ share/python-wheels/
31
+ *.egg-info/
32
+ .installed.cfg
33
+ *.egg
34
+ MANIFEST
35
+
36
+ # PyInstaller
37
+ # Usually these files are written by a python script from a template
38
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
39
+ *.manifest
40
+ *.spec
41
+
42
+ # Installer logs
43
+ pip-log.txt
44
+ pip-delete-this-directory.txt
45
+
46
+ # Unit test / coverage reports
47
+ htmlcov/
48
+ .tox/
49
+ .nox/
50
+ .coverage
51
+ .coverage.*
52
+ .cache
53
+ nosetests.xml
54
+ coverage.xml
55
+ *.cover
56
+ *.py,cover
57
+ .hypothesis/
58
+ .pytest_cache/
59
+
60
+ # Translations
61
+ *.mo
62
+ *.pot
63
+
64
+ # Django stuff:
65
+ local_settings.py
66
+ db.sqlite3
67
+ db.sqlite3-journal
68
+
69
+ # Flask stuff:
70
+ instance/
71
+ .webassets-cache
72
+
73
+ # Scrapy stuff:
74
+ .scrapy
75
+
76
+ # Sphinx documentation
77
+ docs/_build/
78
+
79
+ # PyBuilder
80
+ target/
81
+
82
+ # Jupyter Notebook
83
+ .ipynb_checkpoints
84
+
85
+ # IPython
86
+ profile_default/
87
+ ipython_config.py
88
+
89
+ # pyenv
90
+ .python-version
91
+
92
+ # pipenv
93
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
95
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
96
+ # install all needed dependencies.
97
+ #Pipfile.lock
98
+
99
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
100
+ __pypackages__/
101
+
102
+ # Celery stuff
103
+ celerybeat-schedule
104
+ celerybeat.pid
105
+
106
+ # SageMath parsed files
107
+ *.sage.py
108
+
109
+ # Environments
110
+ .env
111
+ .venv
112
+ env/
113
+ venv/
114
+ ENV/
115
+ env.bak/
116
+ venv.bak/
117
+
118
+ # Spyder project settings
119
+ .spyderproject
120
+ .spyproject
121
+
122
+ # Rope project settings
123
+ .ropeproject
124
+
125
+ # mkdocs documentation
126
+ /site
127
+
128
+ # mypy
129
+ .mypy_cache/
130
+ .dmypy.json
131
+ dmypy.json
132
+
133
+ # Pyre type checker
134
+ .pyre/
@@ -0,0 +1,26 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ title: SlabTerminator
4
+ abstract: >-
5
+ Enumerate the symmetrically unique slab terminations of a bulk crystal for a
6
+ given Miller index from crystal symmetry, and generate slabs.
7
+ type: software
8
+ authors:
9
+ - family-names: Schindler
10
+ given-names: Peter
11
+ email: p.schindler@northeastern.edu
12
+ affiliation: Northeastern University
13
+ repository-code: "https://github.com/d2r2group/slabterminator"
14
+ url: "https://github.com/d2r2group/slabterminator"
15
+ license: MIT
16
+ version: 0.1.0
17
+ date-released: "2026-07-24"
18
+ keywords:
19
+ - materials science
20
+ - surface science
21
+ - crystallography
22
+ - slab
23
+ - surface termination
24
+ - Miller index
25
+ - symmetry
26
+ - pymatgen
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Schindler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,333 @@
1
+ Metadata-Version: 2.4
2
+ Name: slabterminator
3
+ Version: 0.2.0
4
+ Summary: Enumerate the symmetrically unique slab terminations of a bulk crystal for a given Miller index from crystal symmetry, and generate slabs.
5
+ Project-URL: Homepage, https://github.com/d2r2group/slabterminator
6
+ Project-URL: Repository, https://github.com/d2r2group/slabterminator
7
+ Author-email: Peter Schindler <p.schindler@northeastern.edu>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.md
10
+ Keywords: Miller index,crystallography,materials science,pymatgen,slab,surface science,surface termination,symmetry
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
16
+ Classifier: Topic :: Scientific/Engineering :: Physics
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: pymatgen>=2026.5.4
19
+ Description-Content-Type: text/markdown
20
+
21
+ # SlabTerminator
22
+
23
+ Enumerate the **symmetrically unique slab terminations** of a bulk crystal for a
24
+ given Miller index from crystal symmetry.
25
+
26
+ Given a bulk `pymatgen` `Structure` and a Miller index, `SlabTerminator` finds every
27
+ distinct way the crystal can be cleaved along that plane, tells you which
28
+ terminations are polar vs. nonpolar, and (optionally) builds the ready-to-use slab
29
+ structures with vacuum.
30
+
31
+ ### Why not just use pymatgen?
32
+
33
+ pymatgen's `SlabGenerator.get_slabs()` enumerates candidate cleaves by clustering
34
+ atoms along the normal within a tolerance, builds a full slab for each, and then
35
+ deduplicates those slabs by comparing them pairwise with `StructureMatcher` (a
36
+ tolerance-based lattice-reduction + site-matching comparison). `SlabTerminator` instead
37
+ works purely from group theory: it projects the oriented cell's space-group operations
38
+ onto the surface normal and groups candidate cleaves into symmetry orbits *before* any
39
+ vacuum is added (see [How it works](#how-it-works)), so it never builds a slab just to
40
+ decide uniqueness and never runs a pairwise structure comparison. Two practical
41
+ consequences:
42
+
43
+ - **It is much faster.** On our benchmark of 1352 (structure, Miller) cases,
44
+ `SlabTerminator` is roughly **26 to 82× faster** than `SlabGenerator` when building
45
+ slabs (**69× on average**) and **~350× faster on average** when only counting terminations,
46
+ because it skips both the per-cleave slab construction and the pairwise `StructureMatcher`
47
+ deduplication.
48
+
49
+ <div align="center"><img src="scripts/benchmark_speedup_vs_size.png" width="70%" alt="Speedup of SlabTerminator over pymatgen SlabGenerator as a function of problem size and Miller index"></div>
50
+
51
+ *Per-case speedup (pymatgen `SlabGenerator` ÷ `SlabTerminator`) vs. problem size and
52
+ Miller index, over 1352 cases up to max index 3. Both run at a single fixed layer
53
+ tolerance (`tol` = pymatgen `ftol` = 0.1, no auto scan); matched geometry (≥ 8 Å slab,
54
+ 10 Å vacuum, `max_normal_search=1`, centered), best of 3 runs, on an Apple M5 (heat can
55
+ throttle absolute timings, but methods are timed interleaved so the relative speedup is
56
+ barely affected) / pymatgen 2026.5.4. "Cleavage planes" = candidate interlayer
57
+ cleaves before symmetry reduction. Per-case data in `scripts/benchmark.csv`.*
58
+
59
+ - **That speed makes it more robust, by making tolerance sweeps cheap.** Both methods
60
+ share a layer-grouping tolerance (`SlabTerminator`'s `tol`, pymatgen's `ftol`) that
61
+ changes the count: too tight over-splits near-coplanar atoms, too loose merges
62
+ distinct terminations. Any single tolerance is a guess. Because `SlabTerminator`'s
63
+ analysis reuses one cached oriented cell and its symmetry operations — no slab
64
+ rebuilds, no extra spglib calls — sweeping a whole grid of tolerances is nearly free,
65
+ so it can report the count as a function of tolerance and pick a stable plateau
66
+ automatically (`scan_termination_stability()` / `tol="auto"`; see [Choosing the
67
+ tolerance automatically](#choosing-the-tolerance-automatically)). Running the same
68
+ sweep through `get_slabs` means rebuilding every slab and re-running the pairwise
69
+ comparison at each tolerance, expensive enough that in practice one picks a single
70
+ `ftol` and trusts it.
71
+
72
+ ## How it works
73
+
74
+ The oriented unit cell is periodic along the surface normal. `SlabTerminator` runs
75
+ two complementary symmetry analyses, one on each side of adding vacuum:
76
+
77
+ - **Without vacuum → which cleaves are the same slab.** The cell's space-group
78
+ operations are projected onto the 1D coordinate along the surface normal as
79
+ `g → ±g + τ`. Candidate interlayer gaps are grouped into orbits under these maps;
80
+ each orbit is one unique termination. Screw axes, glide planes, and pure
81
+ c-translations (which only exist while the cell is periodic along the normal) are
82
+ what relate cleaves recurring at different heights, so this must be done *before*
83
+ vacuum is added.
84
+ - **With vacuum → is a slab polar.** Each built slab-with-vacuum is checked for a
85
+ surviving operation that maps the normal to its negative. If one exists the two
86
+ faces are equivalent (**nonpolar**); otherwise the slab is **polar**. This must be
87
+ done *with* vacuum, since the periodic cell can otherwise report a false symmetry
88
+ through a glide/screw whose translation the vacuum breaks.
89
+
90
+ This is both faster and more robust than fingerprint-based enumeration. See
91
+ [`scripts/benchmark.py`](scripts/benchmark.py) (which stores every per-case count and
92
+ timing in `scripts/benchmark.csv` — its only generated artifact, alongside the meta
93
+ sidecar) for a correctness + speed comparison against `UniqueSlabsGenerator`, pymatgen's
94
+ `SlabGenerator`, and `StructureMatcher`. By default it recomputes every timing; the
95
+ `--reuse` flag pulls whole method **groups** — `new` (SlabTerminator), `old` (old
96
+ fingerprint), `pmg` (pymatgen) — from the existing CSV instead of re-measuring them, so
97
+ e.g. `--reuse pmg` re-times the fast ST methods fresh while keeping the slow, cached
98
+ pymatgen numbers (each group keeps its own "measured at" timestamp in the meta sidecar),
99
+ and `--reuse all` is report-only. [`scripts/benchmark_analysis.py`](scripts/benchmark_analysis.py)
100
+ reads that CSV to plot the speedup-vs-size analysis (`scripts/benchmark_speedup_vs_size.png`)
101
+ and write `scripts/benchmark_summary.md`.
102
+
103
+ ## Installation
104
+
105
+ Requires Python ≥ 3.12. The project uses [`uv`](https://docs.astral.sh/uv/):
106
+
107
+ ```bash
108
+ uv sync
109
+ ```
110
+
111
+ The only runtime dependency is `pymatgen`.
112
+
113
+ ## Quick start
114
+
115
+ ```python
116
+ from pymatgen.core import Structure
117
+ from slabterminator.core import SlabTerminator
118
+
119
+ structure = Structure.from_file("tests/test-cifs/Fe3C_mp-13154_conventional_standard.cif")
120
+
121
+ # Analyze the (1, 0, 1) surface.
122
+ gen = SlabTerminator(structure, (1, 0, 1))
123
+
124
+ # Cheap: just enumerate the unique terminations (no slabs built).
125
+ for term in gen.get_unique_terminations():
126
+ print(term)
127
+ # Termination(gap_index=..., gap_position=0.125, multiplicity=..., symmetric_by_bulk=False)
128
+ # ... 4 terminations for Fe3C(101)
129
+
130
+ # Full: build one slab per unique termination, with vacuum.
131
+ result = gen.get_unique_slabs(
132
+ vacuum_size=15.0, # Angstrom of vacuum along c
133
+ min_slab_thickness=10.0, # grow the slab until it exceeds this thickness (Angstrom)
134
+ max_normal_search=1, # search for a more orthogonal output cell
135
+ )
136
+
137
+ print(result.properties.n_unique_terminations) # 4
138
+ print(round(result.properties.surface_area, 2)) # 27.56
139
+
140
+ for i, entry in enumerate(result.slabs):
141
+ print(i, round(entry.shift, 4),
142
+ "polar" if not entry.is_symmetric_with_vacuum else "nonpolar",
143
+ entry.top_layer_composition, "/", entry.bottom_layer_composition)
144
+ entry.slab.to(filename=f"Fe3C_101_{i}.cif") # entry.slab is a pymatgen Structure
145
+ ```
146
+
147
+ Output:
148
+
149
+ ```
150
+ 0 0.125 polar Fe / Fe
151
+ 1 0.1844 polar Fe / C
152
+ 2 0.2292 polar C / Fe
153
+ 3 0.4553 polar Fe / Fe
154
+ ```
155
+
156
+ ## API
157
+
158
+ ### `SlabTerminator(structure, miller_index, tol=0.1, symprec=0.1, sym_tol=1e-3, slab_symprec=None, tol_scan=None)`
159
+
160
+ Constructs the analyzer for one `(structure, miller_index)` pair. The symmetry
161
+ analysis runs here, on the cheapest oriented cell, and is independent of how output
162
+ slabs are later built. Raises `ValueError` for the `(0, 0, 0)` index.
163
+
164
+ `tol` is the layer c-tolerance (Angstrom) used to group atoms into atomic layers.
165
+ Pass `tol="auto"` to have it chosen automatically from a tolerance-stability scan
166
+ (see [Choosing the tolerance automatically](#choosing-the-tolerance-automatically)
167
+ below); `tol_scan` overrides the tolerances swept in that case.
168
+
169
+ - **`get_unique_terminations()`** → `list[Termination]`, one per unique termination,
170
+ each with `gap_index`, `gap_position` (fractional c of the cleave), `multiplicity`
171
+ (number of candidate cleaves that collapsed into it), and `symmetric_by_bulk`
172
+ (cheap pre-vacuum face-symmetry estimate). No slab structures are built; this is the
173
+ fast path.
174
+
175
+ - **`get_unique_slabs(...)`** → `UniqueSlabsResult`, building one slab per unique
176
+ termination. Key options:
177
+ - `vacuum_size` (default `10.0`): vacuum thickness in Angstrom.
178
+ - `slab_thickness_cells` / `min_slab_thickness`: stack the oriented cell to a fixed
179
+ number of repeats, or grow it until it exceeds a target thickness in Angstrom.
180
+ - `all_unique_terminations_to_top` (default `False`): also emit the mirrored
181
+ counterpart of each *polar* slab (the other face brought to the top).
182
+ - `center_slab` (default `True`): center the slab along c, else leave vacuum on top.
183
+ - `max_normal_search` (default `None`): search for a more orthogonal (but thicker)
184
+ output cell. Affects only slab geometry, not which terminations are found.
185
+ - `force_orthogonal_cell` (default `False`): force c orthogonal to the surface plane
186
+ as a final step (see docstring for caveats).
187
+
188
+ The returned `UniqueSlabsResult` is a `NamedTuple`:
189
+ - `properties`: `n_unique_terminations`, `surface_area`, and
190
+ `is_surface_symmetric_without_vacuum`.
191
+ - `settings`: the resolved settings actually used (handy for reproducibility),
192
+ including `tol`, the layer c-tolerance actually applied (the plateau value when
193
+ `tol="auto"`), so a run can be reproduced exactly by passing that float back.
194
+ - `oriented_unit_cell`: the cell the slabs were built in.
195
+ - `slabs`: a list of `SlabEntry`, each with `shift`, `is_symmetric_with_vacuum`,
196
+ `top_layer_composition`, `bottom_layer_composition`, `slab` (a pymatgen
197
+ `Structure`), and `face` (`'as_cut'` or `'mirrored'`).
198
+
199
+ ### `regenerate_slabs(shifts, vacuum_size, oriented_unit_cell, ...)`
200
+
201
+ Rebuilds the final slab `Structure`s directly from stored `UniqueSlabsResult` fields,
202
+ skipping the whole symmetry analysis; useful for persisting a compact result
203
+ (shifts + oriented cell + settings) and reconstructing the slabs later.
204
+
205
+ ### Choosing the tolerance automatically
206
+
207
+ The number of unique terminations can depend on the layer c-tolerance `tol`: too
208
+ tight over-splits near-coplanar atoms into separate terminations, too loose merges
209
+ genuinely distinct ones. This is the same shift-enumeration tolerance pymatgen's
210
+ `SlabGenerator` exposes as `ftol`.
211
+
212
+ `scan_termination_stability()` sweeps a grid of tolerances and reports how the count
213
+ varies, reusing the cached oriented cell and symmetry operations (no rebuild, no
214
+ extra spglib calls, so the whole scan is nearly free):
215
+
216
+ ```python
217
+ scan = SlabTerminator(structure, (3, 2, 3)).scan_termination_stability()
218
+ print(scan.curve) # [(0.01, 8), (0.015, 8), ..., (0.1, 3), ...]
219
+ print(scan.chosen_tol) # 0.0612 -- the selected plateau tolerance
220
+ print(scan.chosen_count) # 5
221
+ print(scan.is_ambiguous) # False
222
+ ```
223
+
224
+ Passing `tol="auto"` to the constructor runs this scan and adopts the selected
225
+ tolerance, all on the single oriented cell (no second build):
226
+
227
+ ```python
228
+ gen = SlabTerminator(structure, (3, 2, 3), tol="auto")
229
+ print(gen.tol) # 0.0612 (also gen.tol_scan_result -> TolScanResult)
230
+ print(gen.get_unique_slabs().settings.tol) # 0.0612 (recorded for reproducibility)
231
+ ```
232
+
233
+ Selection **anchors on the conventional default `0.1`** and only overrides it with
234
+ cause. The count-vs-tol curve is usually a monotone step-down whose two ends are
235
+ traps (the fine end over-splits, the loose end collapses toward a single
236
+ termination), so the rule is:
237
+
238
+ - if `0.1`'s count is stable (shared with a neighboring tol), **keep `0.1`** (auto
239
+ is a no-op for the common case);
240
+ - if `0.1` sits on a lone one-tol ledge, pick the widest **interior** plateau (a run
241
+ touching neither scan end, excluding both saturations); its tolerance is the
242
+ geometric mean of the plateau's endpoints;
243
+ - if `0.1` is a ledge with no interior plateau, the count is genuinely
244
+ tolerance-ambiguous: **keep `0.1`** and set `is_ambiguous=True` (with a warning).
245
+
246
+ So `tol="auto"` never returns a degenerate over-merged or over-split count; where the
247
+ answer is truly resolution-dependent it says so rather than guessing.
248
+
249
+ ### `slabterminator.utils`
250
+
251
+ Helpers used by the core, including
252
+ `get_symmetrically_distinct_miller_indices(structure, max_index)` to enumerate the
253
+ Miller indices worth analyzing:
254
+
255
+ ```python
256
+ from slabterminator.utils import get_symmetrically_distinct_miller_indices
257
+
258
+ for miller in get_symmetrically_distinct_miller_indices(structure, max_index=1):
259
+ result = SlabTerminator(structure, miller).get_unique_slabs()
260
+ print(miller, result.properties.n_unique_terminations)
261
+ ```
262
+
263
+ ## Batch generation over many materials
264
+
265
+ `SlabTerminator` handles one `(structure, Miller index)` pair. Two higher-level
266
+ modules build on it for high-throughput datasets: one bulk material in, all its
267
+ slabs out — and many materials in parallel.
268
+
269
+ ### `slabterminator.pipeline` — one material, all Miller indices
270
+
271
+ `build_slabs_for_material(structure, config)` enumerates the symmetrically distinct
272
+ Miller indices (up to `config.max_miller_index`), runs `SlabTerminator` on each, and
273
+ returns a flat list of records — one per built slab — instead of raising on a bad
274
+ material (it returns `MaterialResult(ok=False, error=...)` so a batch can keep going).
275
+ Parameters are grouped into a single `SlabGenConfig` rather than a long argument list:
276
+
277
+ ```python
278
+ from slabterminator.pipeline import build_slabs_for_material, SlabGenConfig
279
+
280
+ config = SlabGenConfig(
281
+ max_miller_index=3,
282
+ tol="auto", # pick each surface's layer tolerance from its plateau
283
+ min_slab_thickness=15.0,
284
+ vacuum_size=15.0,
285
+ center_slab=False,
286
+ )
287
+ result = build_slabs_for_material(structure, config, material_id="mp-13154")
288
+
289
+ print(result.ok, result.n_slabs) # True 90
290
+ for rec in result.records:
291
+ print(rec.miller, rec.term_index, rec.face,
292
+ "polar" if not rec.is_symmetric_with_vacuum else "nonpolar",
293
+ rec.top_layer_composition, "/", rec.bottom_layer_composition)
294
+ # rec.slab is the with-vacuum pymatgen Structure; rec.oriented_unit_cell + rec.shift
295
+ # reconstruct the no-vacuum slab via regenerate_slabs(vacuum_size=0.0).
296
+ ```
297
+
298
+ Each `SlabRecord` carries the built `slab`, its `shift`, `oriented_unit_cell`, and the
299
+ per-slab and aggregate properties (`is_symmetric_with_vacuum`,
300
+ `is_surface_symmetric_without_vacuum`, `surface_area`, `n_unique_terminations`, the
301
+ resolved `tol` and `max_normal_search`, …). The no-vacuum slab is not stored: the
302
+ oriented cell plus the shift reconstruct it exactly via `regenerate_slabs`.
303
+
304
+ ### `slabterminator.batch` — many materials in parallel
305
+
306
+ `run_batch(materials, config, *, on_result, ...)` fans `build_slabs_for_material` out
307
+ across worker processes and streams each finished `MaterialResult` to a sink callback.
308
+ It is scheduler- and output-agnostic: you provide the `(id, structure)` stream and an
309
+ `on_result` writer (CSV, database, in-memory list, …). A material whose worker overruns
310
+ `max_material_seconds` (or crashes) is killed and recorded as a failure rather than
311
+ stalling the run — this is why it uses raw processes rather than a pool, whose futures
312
+ cannot interrupt a running task.
313
+
314
+ ```python
315
+ from slabterminator.batch import run_batch
316
+
317
+ records = []
318
+ def sink(result):
319
+ if result.ok:
320
+ records.extend(result.records)
321
+
322
+ materials = [("mp-13154", struct_a), ("mp-2657", struct_b)] # structures or as_dict() forms
323
+ n = run_batch(materials, config, n_workers=4,
324
+ max_material_seconds=3 * 3600, on_result=sink)
325
+ ```
326
+
327
+ ## Testing
328
+
329
+ ```bash
330
+ uv run pytest
331
+ ```
332
+
333
+ Fixtures for a range of space groups live in [`tests/test-cifs/`](tests/test-cifs/).