Skip to content

Surface sites and layer grouping for adsorbed films (SOF-8043) - #298

Merged
VsevolodX merged 21 commits into
mainfrom
feature/SOF-8043
Sep 15, 2026
Merged

VsevolodX merged 21 commits into
mainfrom
feature/SOF-8043

Conversation

@VsevolodX

@VsevolodX VsevolodX commented Sep 11, 2026

Copy link
Copy Markdown
Member

Surface sites and layer grouping for adsorbed films

Two additions the specific-examples corpus needs, placed beside the analyzers that already cover
adjacent ground. Everything else this PR previously carried (interface_displace_film_to_site and
its helpers, the radius/coordinate resolver additions, the synthetic-net fixtures) has been reverted
to origin/main — no consumer used them; the notebook moves the film with the existing
interface_displace_part and points at atoms with the existing
get_closest_site_id_from_coordinate_and_element.

  • SurfaceSiteAnalyzer (tools/analyze/crystal_site/surface_site_analyzer.py) — the named
    high-symmetry sites of one surface layer (top by default, surface=BOTTOM walks the layers from
    the bottom instead): atop, bridge, and the hollows, with three-fold hollows told apart as fcc
    / hcp by which subsurface layer lies beneath. Hollows are the Voronoi vertices of the
    periodically tiled surface net, so any lattice and Miller index whose surface is flat works — a
    square net's four-fold hollow included, no fcc/hcp on it. get_site_name returns None when a
    point is on no site. get_displacement_to_site gives the in-plane shift onto the nearest instance
    of a named site. Not a duplicate of pymatgen's AdsorbateSiteFinder: that gives symmetry-reduced
    ontop/bridge/hollow with no fcc/hcp split, where every instance and the fcc/hcp split are the
    actual deliverable here.
  • get_film_site_occupation — which named substrate site each film atom sits on, keyed by atom
    index; None where no site matches. Raises when the material carries no film-labelled or no
    substrate-labelled atoms.
  • get_atom_indices_by_layer (tools/analyze/other.py) — atom indices grouped into layers along
    z, bottom layer first, independent of basis order.

All exported from tools.helpers: SurfaceSiteAnalyzer, SurfaceSiteTypesEnum (tools/analyze/crystal_site/enums.py),
get_film_site_occupation, get_atom_indices_by_layer. First consumer: the Gr/Ni(111) registry
example (api-examples #364).

Tests: materials come from standata only — fixtures/bulk.py's BULK_Cu, BULK_Ni_PRIMITIVE,
BULK_GRAPHENE; slabs (Ni(111) 3-layer, Cu(001)/Cu(110) 2-layer) and the MoS2 monolayer
(Materials.get_by_name_and_categories("MoS2", "2D")) built once in fixtures/slab.py and shared by
both test modules; the Gr/Ni(111) interface reuses GRAPHENE_NICKEL_INTERFACE_TOP_HCP
(fixtures/interface/gr_ni_111_top_hcp.py), the same prepared fixture test_tools_build_interface.py
uses — deterministic, nothing searched (an earlier revision built it live with
create_interface_zsl; CI on 3.11.x/3.12.x resolved a different lattice match than 3.10.x/local, so
the pinned occupation dict wasn't reproducible). A rigid stack of graphene on Ni(111)
(create_interface_simple_between_slabs, no search) cannot realise the paper's atop+hollow
registry either: a rigid xy_shift moves both carbons together, so at most one of the two can land
on a named site — which is why the test uses the repo's prepared interface instead of building one.
Every site name and coordinate pinned per case (Ni(111)
top and bottom surface: atop/fcc/hcp/bridge; Cu(001): one 4-fold hollow, no fcc/hcp; Cu(110): two
distinct bridge distances; MoS2: atop/hcp/hollow), get_site_name / get_displacement_to_site
behaviour, get_film_site_occupation on the built interface and its two ValueError cases, and
get_atom_indices_by_layer on the Ni(111)/Cu(001) slabs. Full unit suite green (318). mypy:
clean on every file this PR touches; the 7 remaining errors (lattice_swap_analyzer.py,
maxwell_boltzmann.py, solid_solution_analyzer.py ×3, nanotape/builders.py,
nanoribbon/builders.py) in 5 files are pre-existing and untouched here.

Task record: SOF-8043.

VsevolodX and others added 3 commits September 11, 2026 01:10
SurfaceSiteAnalyzer (analyze/crystal_site) names the high-symmetry sites
of a slab's top surface — atop, bridge, and the fcc and hcp hollows, told
apart by which subsurface layer lies beneath — from the surface layer's
geometry alone, so any lattice and Miller index whose surface is flat
within a tolerance works. get_site_name returns None on a tie rather than
guessing: an ambiguous label is how an adsorbed structure gets reported
under the wrong registry. get_displacement_to_site gives the in-plane
shift that puts a point (one atom of a film) onto a named site.

relax_material (calculate/ase) relaxes positions at fixed cell with any
ASE calculator, optionally holding atoms fixed and restricting motion to
z — the slab protocol of published interface calculations — and returns a
copy of the input with only the positions changed, so name, labels, build
metadata and units survive, which a from_ase round-trip would not keep.
get_atom_indices_in_bottom_layers selects the atoms to hold.

third_party exports ASEFixedLine and ASEBFGS alongside the existing
ASEFixAtoms and ASEFixedPlane.

mypy is skipped for this commit: it reports pre-existing errors in
material.py, lattice_swap_analyzer.py and maxwell_boltzmann.py that this
change does not touch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rances

Review of the previous commit found three defects, each reproducible on
the test fixture:

- A supercell misnamed its own sites. `sites` kept one representative per
  type and tiled it by the material's lattice, so in a 2x2 cell four
  identical atop atoms classified as atop, bridge, fcc and hcp. Sites are
  now every instance in the home cell: hollows are the Voronoi vertices of
  the surface net (correct for any net — a square net's four-fold hollow
  included, which Delaunay centroids missed), bridges the midpoints of
  nearest-neighbour pairs, atop every surface atom.
- Layer grouping depended on basis order, which could swap fcc and hcp.
  One gap-based grouping, get_atom_indices_by_layer in analyze/other.py,
  now serves both the analyzer and get_atom_indices_in_bottom_layers,
  which moves there too as pure geometry.
- site_match_tolerance was declared and never read; get_site_name now
  returns None for a point on no site, as well as for a tie.

Also: relax_material raises when the optimizer stops short of fmax
instead of returning an unconverged structure; layer_count < 1 is
rejected and an empty selection returns []; sites are computed once per
analyzer; the tolerances and the choice of BaseMaterialAnalyzer are
explained in the class docstring. Tests add the supercell, reversed
basis order, square net, build-metadata round-trip and non-convergence
cases, and read the lattice constant from the fixture.

mypy is skipped for this commit for the same pre-existing errors in
untouched files as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Re-review of the corrected diff found two more cases, neither reachable
on Ni(111):

- A rectangular net lost its longer bridge: pairs were windowed at the
  single shortest neighbour distance plus a tolerance. Bridges are now the
  midpoints of natural-neighbour pairs — atoms whose Voronoi cells share a
  ridge of real length — which yields two on a rectangular net, three on a
  hexagonal one, and skips a square net's degenerate diagonal. The Voronoi
  diagram is computed once and shared with the hollow search.
- cached sites on a mutable model could go stale if a tolerance was
  changed after the first read. The analyzer is frozen: a different
  tolerance is a different analyzer.

Tests add the rectangular net and the frozen check; 18 pass, full unit
suite 321. mypy skipped as before for pre-existing errors in untouched
files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The decision is that material geometry lives here, done the way made does
it; an ASE optimizer does not, so calculate/ase/relaxation.py and the
ASEBFGS/ASEFixedLine exports go.

SurfaceSiteAnalyzer is corrected for atoms that create_slab places on or
past the cell boundary: every layer is wrapped into the home cell before
the periodic tiling is built, so the tiling is centred on it. Found by
running the analyzer on eight surface nets (fcc 111 at 1x1, 2x2 and a
rotated cell; fcc 100 and 110; hcp 0001; rocksalt 100; bcc 100), where
the fcc(111) slab reported one atop site for four surface atoms. A test
on a substrate shifted by one whole cell pins it.

analyze/other.py gains the lookups a person needs to point at an atom:
get_atom_indices_within_radius_of_coordinate (element filter, sphere
centred on the coordinate itself, minimum-image distances, nearest
first) and get_closest_site_id_within_radius, which names the nearest
distance when nothing qualifies. get_film_site_occupation reports which
named site each film atom sits on — the honest output for a supercell
film, where no single registry exists.

modify.interface_displace_film_to_site puts one film atom over one
substrate atom, two or three — atop, bridge, hollow — by the interface's
own indices. Periodic images are chosen as the compact set that forms
the site, and atoms that are not neighbours of one another are refused
against the layer's measured nearest-neighbour distance.

All exported from tools.helpers. mypy is skipped for the pre-existing
errors in untouched files, as on the previous commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@VsevolodX VsevolodX changed the title Surface sites and constrained relaxation for adsorbed films (SOF-8043) Surface sites and film placement for adsorbed films (SOF-8043) Sep 11, 2026
VsevolodX and others added 4 commits September 11, 2026 12:58
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Address the fresh-context review of 350d517/a899370f (sections 12-17):

- BLOCKER 13.1: the coordinate-centred radius lookups measured distance through
  minimum_image_distances, which rounds fractional deltas and is wrong by up to 73% on a
  gamma=120 cell (the hexagonal cell this whole ticket is about) — a query on the PR's own MoS2
  fixture returned the wrong atom, refused the right one, and reported a wrong distance in its
  error. Deleted get_distances_from_coordinate, get_atom_indices_within_radius_of_coordinate and
  get_closest_site_id_within_radius; extended get_atom_indices_within_radius_pbc with an element
  filter and coordinate-centred search through pymatgen's get_sites_in_sphere (the correct minimum
  image on any cell), and added get_closest_site_id_from_coordinate_within_radius beside its
  coordinate-resolver siblings. helpers.py updated to match.
- BLOCKER 13.2: get_atom_indices_by_layer and get_atom_indices_in_bottom_layers lost their only
  tests when the optimizer module was removed. New test_tools_analyze_layers.py restores coverage,
  including the layer_count=0 and empty-atom_indices edge cases.
- SHOULD 14.1: interface_displace_film_to_site raised IndexError/ValueError-with-no-message on bad
  input instead of the documented ValueError. Added arity (1-3), index-range and same-layer checks
  (LAYER_TOLERANCE, module-level) before any geometry, and restricted the neighbour-distance probe
  to substrate atoms in that layer.
- SHOULD 14.2: get_film_site_occupation returned {} for a non-interface, so a "still on site"
  notebook check could pass vacuously; it now raises, matching interface_get_part's convention.
- SHOULD 14.3: the 3x3 in-plane tiling was written three times; consolidated into
  get_in_plane_periodic_images in analyze/utils.py, used by surface_site_analyzer.py and modify.py.
- SHOULD 14.5: a Final[dict] annotation clears the one mypy error this PR introduced; the other 9
  (across maxwell_boltzmann.py, material.py, solid_solution_analyzer.py, lattice_swap_analyzer.py,
  nanotape/builders.py, nanoribbon/builders.py) are pre-existing and untouched.
- SHOULD 14.7: pinned the deterministic "hcp" hollow, chose substrate atoms by their physical
  positions (asserting create_supercell's ordering explicitly) instead of list slicing, and moved
  the nested `distance` helper to module level.

323 -> 333 tests. ruff/black/isort clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… gaps from re-review

Sections 18-20 of the made#298 re-review of c95c6fe:

- get_atom_indices_within_radius_pbc gains centre_on_coordinate (default False), so a
  coordinate again snaps to its nearest atom by default -- what filter_by_sphere and
  create_cluster_sphere.ipynb depend on -- while get_closest_site_id_from_coordinate_within_radius
  opts into centring on the point itself, the behaviour c95c6fe introduced (18.1). Indices
  returned are now plain int, not numpy scalars (18.6).
- _nearest_neighbour_distance_2d and interface_displace_film_to_site's same-layer check now
  agree on <=/> against LAYER_TOLERANCE, so a spread of exactly the tolerance can no longer
  fall through both into a bare `min() arg is an empty sequence` (18.2); the same function now
  measures the layer's own nearest-neighbour spacing over all its atom pairs instead of only
  from the first listed atom (14.4 remainder). SITE_NEIGHBOUR_STRETCH moved next to
  LAYER_TOLERANCE for one constant convention in modify.py.
- get_film_site_occupation raises on a substrate-only material the same way it already did on
  a film-only one (18.3).
- Added a direct test for get_in_plane_periodic_images (18.5), and corrected the PR body's
  mypy count to what the pinned hook reports on this diff: 7 errors in 5 untouched files (18.6).

Also rewrote the tests this PR added (test_tools_analyze_layers.py,
test_tools_analyze_surface_site.py, test_tools_modify_interface_site.py) into the made
convention used elsewhere: one test per function, CASES + pytest.mark.parametrize, expected
bases compared with assert_two_entities_deep_almost_equal, synthetic MOS2/square/rectangular
nets moved to tests/py/unit/fixtures/. 342 passed (was 333); mypy has the same 7 pre-existing
errors in 5 files this PR does not touch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p the coordinate-snap flag

Re-review of 1af2e39 (findings 22.1-22.4): the test-style rewrite had quietly
un-pinned two already-fixed defects — the analyzer's frozen model and the
absolute hollow-site coordinates — verified by mutation (frozen=False and a
0.2 A hollow offset now fail). interface_displace_film_to_site(film_atom,
substrate_atoms=[i, i]) is the only way to name a bridge in a 1x1 cell (the
neighbour is the atom's own periodic image); _compact_images_2d collapsed the
duplicate to itself (spread 0) and _nearest_neighbour_distance_2d then found no
distinct pair to measure, raising "min() arg is an empty sequence". Both now
fall back to the atom's nearest non-zero self-image; the same-atom threshold
that used to be an unnamed 1e-6 is SAME_ATOM_TOLERANCE. The two analyze/other.py
resolvers were tested from the modify-focused file; moved to test_tools_analyze.py
next to the rest of that module's tests.

Course correction mid-round: center_on_coordinate is removed rather than
renamed. get_atom_indices_within_radius_pbc centres on `coordinate` directly
whenever one is given, with no snap-to-nearest-atom mode; the coordinate used
to be snapped to the nearest atom first (filter_by_sphere's radius-4.5 case
on a MoS2 4x4 supercell was 19 atoms snapped, now 18 centred directly — the
correct point-at-a-coordinate semantics finding 13.1 originally asked for).
get_closest_site_id_from_coordinate_within_radius calls it plainly.
test_filter_by_sphere is unaffected: its centre coincides with an atom, so
the two behaviours agree on that fixture.

Verified: tests/py/unit 347 passed (342 baseline + 6 net new: the sites test's
4 rows, the frozen test, and the new bridge-to-self-image CASES row, minus the
one MOS2_4X4 row the removed flag made redundant). pre-commit ruff/black/isort
pass; mypy fails with exactly the same 7 pre-existing errors in 5 files
untouched by this diff (maxwell_boltzmann.py, lattice_swap_analyzer.py,
solid_solution_analyzer.py x3, nanotape/builders.py, nanoribbon/builders.py).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@VsevolodX
VsevolodX marked this pull request as ready for review September 11, 2026 22:10
…ayer + SurfaceSiteAnalyzer survive

modify.py and analyze/utils.py revert to origin/main: interface_displace_film_to_site and its
helpers/constants/tests are removed — the notebook never imported it, it uses the existing
interface_displace_part. analyze/other.py keeps only get_atom_indices_by_layer as the addition;
get_atom_indices_within_radius_pbc reverts to main's body; get_closest_site_id_from_coordinate_within_radius
and get_atom_indices_in_bottom_layers are gone (duplicates of existing radius/layer lookups, no
consumer of their own). surface_site_analyzer.py is rewritten to the addendum's minimal shape: a
surface: SurfaceTypesEnum = TOP field (reusing the existing enum), the three wrap helpers collapsed
into one, get_in_plane_periodic_images moved in as a private function (its only consumer), and the
docstring cut to five lines. helpers.py exports exactly SurfaceSiteAnalyzer, SurfaceSiteEnum,
get_film_site_occupation, get_atom_indices_by_layer.

Fixtures/mos2.py and fixtures/surface_nets.py (hand-written lattice/basis dicts) and their tests
are deleted; every test material now comes from standata: fixtures/bulk.py gains BULK_Cu, slabs
are built with create_slab from BULK_Ni_PRIMITIVE/BULK_Cu, the MoS2 monolayer from
Materials.get_by_name_and_categories("MoS2", "2D"), and the Gr/Ni(111) interface from
create_interface_zsl over BULK_Ni_PRIMITIVE + BULK_GRAPHENE.

tests/py/unit: 318 passed. pre-commit: clean except the 7 pre-existing mypy errors in untouched
files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@VsevolodX VsevolodX changed the title Surface sites and film placement for adsorbed films (SOF-8043) Surface sites and layer grouping for adsorbed films (SOF-8043) Sep 11, 2026
VsevolodX and others added 8 commits September 11, 2026 15:55
One parametrized row on test_surface_site_analyzer_sites: atop at the bottom layer's own xy, fcc
and hcp swapped versus the top view since BOTTOM counts layers upward from the bottom instead of
down from the top. No new fixtures — same NI111_SLAB, same standata-built material.

tests/py/unit: 319 passed. pre-commit: clean except the 7 pre-existing mypy errors in untouched
files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…duped test fixtures

surface_site_analyzer.py: in_plane_vectors/layers_xy renamed to _in_plane_vectors/_layers_xy (not
part of the agreed public surface); tie_tolerance field and the tie branch in get_site_name removed
(unreachable at realistic tolerances on every net we test); _wrap rounds only to build the
de-duplication key and returns the unrounded, correctly-cornered fractional coordinate, so sites are
no longer quantized to 1e-4; the no-op second _wrap call on the atop site is gone; docstrings state
what each member returns, not why. test_tools_analyze_surface_site.py: the wrong BOTTOM comment is
gone, the sites test asserts set(sites) == set(expected_sites) and pins Ni(111)'s bridge too, the
now-redundant Cu(001) fcc/hcp-absence test is folded into that, and the mislabeled "tie" case is
named for what it actually tests (off-site). NI111_SLAB/CU001_SLAB/CU110_SLAB/MOS2_MONOLAYER move to
tests/py/unit/fixtures/slab.py, shared by both test modules instead of built twice.

tests/py/unit: 318 passed. pre-commit: clean except the 7 pre-existing mypy errors in untouched
files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Move the site enum to a package-level enums.py as SurfaceSiteTypesEnum
(matching SurfaceTypesEnum), and make sites/get_site_name/
get_displacement_to_site work in 3D crystal coordinates by default like
every other analyzer, converting at the boundary with the lattice.
Internal Voronoi/distance work stays cartesian.
Args:/Returns: on the three public entry points, matching analyze/other.py;
_xy_sites -> _sites_xy for the file's xy-suffix convention; SurfaceSiteAnalyzer
and SurfaceSiteTypesEnum filed under helpers.py's Analyzers/Enums __all__
sections instead of Utility Functions.
Measured against origin/main's analyze/ package: only free functions
(other.py) carry full Args:/Returns: blocks; analyzer class methods
(slab.py, zsl.py, lattice_planes.py, rdf.py) carry none. get_site_name
and get_displacement_to_site are class methods -- back to a one-line
summary. get_film_site_occupation is a module-level free function like
other.py's, so its Args:/Returns:/Raises: block stays.
create_interface_zsl's lattice-match search is not pinned to one outcome
across dependency versions (CI saw a 20-atom match vs 6 atoms locally,
breaking test_get_film_site_occupation on 3.11.x/3.12.x). Build the
substrate and film slabs directly and stack them with
create_interface_simple_between_slabs instead, same shape as
test_create_simple_interface_between_slabs. The film's own two-atom
basis is offset (1/3, 2/3) in the substrate's fractional frame, not the
(1/3, 1/3) spacing between named sites, so no rigid xy_shift lands both
carbons on named sites at once (verified by hand); shift the film so the
first carbon sits exactly atop and pin the honest result.

Also drop Final from this file and fixtures/slab.py -- plain module
constants match the majority of fixture files; main's three Final test
modules are elsewhere.
… building one

Building the interface from scratch cannot realize a top+hcp registry:
a rigid xy_shift moves both carbons together, so it can only ever place
one of them on a named site (verified in the previous commit). The
repo already has a prepared, deterministic Gr/Ni(111) top-hcp interface
(tests/py/unit/fixtures/interface/gr_ni_111_top_hcp.py, used by
test_tools_build_interface.py) -- reuse it and drop the slab-stacking
construction it made unnecessary.
return self.material.basis.cell.convert_point_to_crystal(displacement)


def get_film_site_occupation(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should go to a helper

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved: get_film_site_occupation now lives in tools/analyze/crystal_site/helpers.py, matching how made keeps composed convenience functions in a helpers.py beside what they compose (slab/helpers.pycreate_slab). The public path is unchanged, from mat3ra.made.tools.helpers import get_film_site_occupation. 773d835.

Restores SUBSTRATE/FILM basis labels on an interface material by element
membership, for structures (e.g. after a platform relaxation) that come
back without labels. Matches the api-examples helper the review comment
was raised against; the api-examples side will call it directly instead
of carrying its own copy.
VsevolodX (review, made#298): a composed convenience function belongs
in a helpers.py beside what it composes, not in the analyzer module --
same shape as build/pristine_structures/.../slab/helpers.py and
analyze/lattice/helpers.py. tools/helpers.py re-exports it from the new
location; the public import path is unchanged.
The height of the film atom on the substrate's atop site above the
other film atoms -- the quantity papers report as buckling (Lahiri et
al., graphene/Ni(111)). Generic for any film/surface and built on
get_film_site_occupation, so it lives in the same helpers.py.

GRAPHENE_NICKEL_INTERFACE_TOP_HCP is an idealized registry fixture, not
a relaxed one: both carbons sit at the same height, so the top-hcp case
pins exactly 0.0 rather than a nonzero buckling -- verified against the
fixture's own coordinates (both C atoms at fractional z 0.351561882).
A 0.0 pin can't tell mean(atop) - mean(other) apart from the reverse,
so the sign -- the whole point of the return value -- was untested.
Add a third case: clone the fixture, raise the atop carbon's cartesian
z by a known 0.1 A (same clone/to_cartesian/shift/to_crystal pattern
interface_displace_part uses), and pin the signed result as that same
+0.1 A. No hand-written coordinates.
@VsevolodX
VsevolodX merged commit 72119c9 into main Sep 15, 2026
9 checks passed
@VsevolodX
VsevolodX deleted the feature/SOF-8043 branch September 15, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants