Piecing (super-large structures)

Voxel library substitution for super-large structures (“piecing”).

Why this exists

Atom-HiFi selects a minimal representative subset of structures from a library by DECAF-clustering atomic environments and running a greedy set-cover, scoring each candidate subset with the fidelity metric F = L / R. This works when the library holds many small-to-medium structures.

It breaks when the library is a handful of very large structures (e.g. ~11 cells of ~1000 atoms). Each giant structure already contains nearly every microstate, so a single structure covers the whole motif vocabulary: the set-cover returns |S| = 1, the bandwidth factor c* inflates, and F saturates. This is an artefact of unit granularity, not chemistry — the selection unit (a whole 1000-atom cell) is far coarser than the thing being covered (a local atomic environment).

The fix: cut each large structure into many small, spatially local pieces and run the selection with pieces as the library units. A bulk piece carries bulk motifs, a defect piece carries defect motifs — the units are now heterogeneous, set-cover regains a meaningful gradient, and F behaves.

Pieces are a statistics-layer cut only

Descriptors are never recomputed. They are computed once on the intact periodic cell (the descriptor’s own r_cut already encodes the local environment), and a piece simply selects rows of that pre-computed matrix. Physically carving atoms out would change the chemical environment of boundary atoms (severed bonds, fake surfaces) and corrupt their descriptors — so we do not do it. A piece is pure index bookkeeping plus a row permutation; the numbers fed to DECAF are bit-identical to the intact-cell numbers.

Because the cut touches only which unit owns each atom (never the descriptor values), the whole downstream stack — Judge, HiFiWorkflow, the 1-D / grid / NSGA-II scans, the set-cover — runs unmodified and unaware that pieces exist. This module is the container boundary: the runner calls make_pieces() to wrap, runs the normal workflow, then calls assign_back() to unwrap the per-atom group labels back onto the original structures.

How a structure is cut: the voxel grid

Pieces are disjoint sub-cells of a fractional-coordinate grid:

  1. Estimate the structure’s atom density ρ = N / V (V = cell volume).

  2. Target voxel edge L = (target_atoms / ρ) ** (1/3) — the cube edge that would, at this density, hold target_atoms atoms.

  3. Divide each cell vector i into n_i = max(1, round(|cell_i| / L)) parts. The grid lives in fractional coordinates, so it is automatically PBC-correct and triclinic-safe (sub-cells are parallelepipeds congruent with the cell, not axis-aligned cubes).

  4. Each atom’s wrapped scaled position lands it in exactly one voxel. Every non-empty voxel becomes one piece; empty voxels are dropped; sparse voxels (a few atoms near a surface) are kept as small pieces — harmless, they just cover little.

target_atoms is therefore a granularity dial, not a hard size: actual piece populations vary with local density. Exposing atom-count rather than a voxel edge in Å keeps the knob system-size- and density-independent.

Spatial locality is mandatory. The partition must be spatial — chopping the atom list into arbitrary index chunks would put a uniform random sample of microstates in every chunk, re-creating the very artefact this module fixes.

Pass-through

Structures with len(atoms) <= 4 * target_atoms are already fine-grained and pass through uncut (one piece == the whole structure). Mixed libraries (a few giants among many small structures) work transparently.

Reporting note

When piecing is active, |S| and fidelity reports count pieces, not structures. Representative output contains unique selected original parents, with every focus atom annotated. assign_back() returns annotated copies of every original structure (motif_id, piece_id, in_selection); when a fitted Judge is available the runner writes these copies to diagnosed.xyz and their three per-atom arrays to diagnosed.npz.

class atom_hifi.piecing.PieceMap(parent_idx, atom_idx, n_orig, target_atoms)[source]

Bases: object

Bookkeeping to invert a make_pieces() cut.

Variables:
  • parent_idx (list[int]) – parent_idx[p] = index of the original structure piece p came from.

  • atom_idx (list[np.ndarray]) – atom_idx[p] = atom indices (into the parent structure’s full-atom array, ascending) that belong to piece p. Piece-local atom j corresponds to parent atom atom_idx[p][j].

  • n_orig (int) – Number of original structures.

  • target_atoms (int) – The granularity dial used for the cut (recorded for provenance).

Parameters:
parent_idx: list
atom_idx: list
n_orig: int
target_atoms: int
property n_pieces: int
atom_hifi.piecing.make_pieces(lib, desc_list, target_atoms)[source]

Cut large structures into disjoint voxel pieces (bookkeeping only).

Parameters:
  • lib (list[ase.Atoms]) – Original structures (full atoms, intact cells).

  • desc_list (list[np.ndarray]) – Per-structure descriptor arrays, desc_list[i] of shape (len(lib[i]), D) — one row per atom, in structure order. Computed on the intact cell; never recomputed here.

  • target_atoms (int) – Granularity dial (approx. atoms per voxel).

Returns:

  • pieces_lib (list[ase.Atoms]) – One Atoms per non-empty piece (subset of a parent; parent cell/pbc retained). Drop-in replacement for lib.

  • pieces_desc_list (list[np.ndarray]) – Matching per-piece descriptor rows (selected from desc_list, never recomputed). Drop-in replacement for the per-structure descriptor list.

  • piece_map (PieceMap) – Inverse-mapping bookkeeping for assign_back().

atom_hifi.piecing.original_representatives(piece_map, lib, selected_piece_idx, per_el_group_libs, species, meta_by_group=None, noise_by_group=None)[source]

Return selected full parents with every focus atom annotated.

Selection remains piece-wise. Parent structures are deduplicated in first selected-piece order, while group ids are gathered from all sibling pieces belonging to each selected parent.

atom_hifi.piecing.assign_back(piece_map, lib, per_el_group_libs, species, selected_piece_idx, ids_are_global=False)[source]

Map per-piece group labels back onto the original structures.

Pure inverse bookkeeping — no inference, no OOD. Each piece’s per-element DECAF group ids (in piece-local ascending atom order, exactly as the focus filter produced them) are scattered onto the parent structure’s atoms.

Parameters:
  • piece_map (PieceMap) – From make_pieces().

  • lib (list[ase.Atoms]) – The original structures (not pieces).

  • per_el_group_libs (dict[str, list]) – per_el_group_libs[el][p] = list of group ids for element el’s atoms of piece p (e.g. judge.per_el_group_libs).

  • species (list[str]) – Focus elements, in the order used to build the joint group space.

  • selected_piece_idx (iterable[int]) – Piece indices chosen by set-cover (the representative subset).

  • ids_are_global (bool) – True when per_el_group_libs already carries refined/sorted global group ids (Judge.apply_refined_group_ids, issue #40) — element offsets are skipped so ids are not offset twice.

Returns:

Copies of the original structures, each carrying integer arrays: motif_id (joint-encoded group id; -1 for non-focus atoms), piece_id (global piece index owning the atom), and in_selection (1 if the owning piece was selected, else 0).

Return type:

list[ase.Atoms]