Runner scripts#

Each stage ships as a thin class plus a self-contained runner script. The runner is what actually does the science, and it is the only part of MatMelt that ever reaches a compute backend: the stage declares it as an input artifact, the backend stages it into the job’s private directory, and the job executes it there.

Three properties follow from that design, and they are contracts rather than conveniences:

  • A runner imports only the standard library and its own science packages — never matmelt. This is what lets a remote machine run MatMelt jobs while holding none of MatMelt’s code. A test asserts it for every runner by parsing the import graph.

  • Every science import is lazy, inside a function body. A runner therefore loads — and answers --help — on a machine with no science stack at all, which is also why building these docs needs no SMACT, PyXtal, MACE, ASE or AiiDA installed.

  • A missing dependency exits 3 with the exact pip install command and the path of the interpreter that lacked it. Guessing the install name is not good enough: mace is installed as mace-torch.

Running one by hand#

Because a runner is self-contained, you can execute it directly on a material’s directory to debug a stage in isolation, without the orchestrator, the state store, or a config file:

$ cd ~/matmelt-work/Cl1Na1_3D-225
$ python3 ~/path/to/screen_runner.py --help
$ python3 ~/path/to/screen_runner.py --cif structure.cif --device cpu

The exit codes are uniform across all of them: 0 the work completed, 2 a usage error or an unusable input, 3 a missing dependency. Note that “completed” is not “the science succeeded” — a screening that rejects a structure still exits 0, because it did screen it. The verdict lives in the file the runner wrote, which is exactly the separation the stage relies on when it interprets the result.

MatMelt compose runner: SMACT charge-balance ONE element combination.

Self-contained by design – imports stdlib + smact only, never matmelt – because it is staged to the compute backend per job and executed there by the user’s own python; remotes never receive our package code. It is equally runnable by hand for isolated debugging:

python compose_runner.py --elements Cl Na
python compose_runner.py --help

Given one element combination (any arity: binary, ternary, …), it asks SMACT for the charge-balanced compositions and writes the fan-out result into the current directory (or --out-dir):

children.json                          # JSON list of child ids (may be [])
children/<formula>/composition.json    # one seed file per child

A child id is the reduced formula with explicit counts (Cl1Na1, Cl2Na1O3): deterministic, filesystem-safe, unambiguous. Compositions that reduce to the same formula under different oxidation-state assignments merge into one child carrying every assignment.

Unary (single-element) seeds are special-cased. SMACT’s charge-neutrality filter yields nothing for one element (no charge-balanced unary compound), so a single --elements X emits the elemental compositions X1..Xn directly (n = --max-unary-atoms, defaulting to --threshold) – allotrope stoichiometries like C2 or C60 for the structure stage to find cells for. Elemental compositions are deliberately never gcd-reduced (reduction would collapse every Xn into X1) and never touch smact at all.

Exit codes: 0 = completed, including zero viable compositions (an empty fan-out is a valid scientific result, not an error); 2 = bad arguments (argparse); 3 = smact is not importable in this python environment (stderr says exactly what to install); 1 = unexpected error (traceback on stderr).

matmelt.core.stages.compose_runner.reduced_counts(counts)[source]#

Reduce a stoichiometry to its smallest whole-number form.

Parameters:

counts (list[int]) – Atom counts per element, all at least 1.

Returns:

The counts divided by their greatest common divisor.

Return type:

list of int

Examples

>>> reduced_counts([2, 2])
[1, 1]
>>> reduced_counts([4, 6, 2])
[2, 3, 1]
matmelt.core.stages.compose_runner.formula_id(symbols, counts)[source]#

Build the child id for one composition.

Parameters:
  • symbols (list[str]) – Element symbols.

  • counts (list[int]) – Their stoichiometric counts, in the same order.

Returns:

Symbols with explicit counts, so Cl1Na1 rather than ClNa. Writing the 1s keeps every id unambiguous and deterministic, which matters because this string becomes a directory name and a material id.

Return type:

str

Examples

>>> formula_id(["Cl", "Na"], [1, 1])
'Cl1Na1'
matmelt.core.stages.compose_runner.merge_compositions(raw)[source]#

Fold SMACT’s charge-balanced tuples into unique children.

Parameters:

raw (list[tuple[Any, Any, Any]]) – (symbols, ox_states, counts) tuples as smact_filter yields them.

Returns:

{formula_id: composition}, where each composition carries formula, elements, stoichiometry and oxidation_states. Stoichiometries are reduced first, so entries landing on the same formula – the same compound reached through different oxidation-state assignments, or an unreduced multiple – merge into one composition carrying every distinct assignment.

Return type:

dict

matmelt.core.stages.compose_runner.unary_compositions(element, max_atoms)[source]#

Enumerate elemental compositions for a single-element seed.

Charge balance cannot apply to one element, so SMACT is never consulted – and never imported – on this path.

Parameters:
  • element (str) – The element symbol.

  • max_atoms (int) – The largest formula unit to emit.

Returns:

{formula_id: composition} for X1 through X<max_atoms>, mirroring merge_compositions’s shape so write_children is reused unchanged. The stoichiometry is the atom count per formula unit and is deliberately not gcd-reduced: C2 and C60 are distinct allotrope candidates, not multiples of C1. The oxidation state of a neutral elemental solid is 0.

Return type:

dict

Examples

>>> sorted(unary_compositions("C", 3))
['C1', 'C2', 'C3']
matmelt.core.stages.compose_runner.discover(elements, threshold)[source]#

Ask SMACT for the charge-balanced compositions of one element combination.

The single SMACT touchpoint in this file. The import is inside the function body, which is what lets the module load – and --help run – in an environment with no science stack at all.

Parameters:
  • elements (list[str]) – Two or more element symbols.

  • threshold (int) – The largest stoichiometric coefficient SMACT explores.

Returns:

(symbols, ox_states, counts) per charge-balanced candidate.

Return type:

list of tuple

Raises:

ImportError – When smact is not installed in this interpreter. main catches it and exits 3 with the exact install command.

matmelt.core.stages.compose_runner.write_children(compositions, out_dir)[source]#

Write one directory per composition, then the manifest.

Parameters:
  • compositions (dict[str, dict[str, Any]]) – {formula_id: composition} to write.

  • out_dir (Path) – The job’s working directory, which is the parent item’s directory.

Return type:

None

Notes

The manifest is the driver’s signal that the fan-out result is complete, so it is written after every child directory and via tmp-then-rename. A crash mid-run therefore leaves child directories but no manifest – never a manifest naming children that do not exist.

matmelt.core.stages.compose_runner.main(argv=None)[source]#

Run the compose stage for one seed.

Parameters:

argv (list[str] | None, default: None) – Command-line arguments. None reads sys.argv.

Returns:

The process exit code: 0 when the fan-out completed, including a fan-out into zero children; 2 for a usage error (argparse’s own); 3 when smact is missing, with the exact pip install command and this interpreter’s path printed to stderr.

Return type:

int

MatMelt structure runner: PyXtal-generate candidates for ONE composition.

Self-contained by design – imports stdlib + pyxtal only, never matmelt – because it is staged to the compute backend per job and executed there by the user’s own python; remotes never receive our package code. It is equally runnable by hand for isolated debugging:

python structure_runner.py --composition composition.json
python structure_runner.py --composition composition.json --dims 3D 2D
python structure_runner.py --help

Given one composition (formula + elements + stoichiometry), it asks PyXtal for one random symmetry-constrained crystal in every group of each requested dimensionality. Each dimensionality is swept over its full symmetry family – 3D = 230 space groups, 2D = 80 layer groups, 1D = 75 rod groups (--dims, default 3D). Groups whose constraints cannot be met are skipped – but counted and reported, never swallowed silently. The fan-out result lands in the current directory (or --out-dir):

children.json                                # JSON list of child ids (may be [])
children/<Formula>_<DIM>-<SG>/structure.cif  # the candidate crystal
children/<Formula>_<DIM>-<SG>/structure.json # provenance: dim, SG, symbol, lattice

A child id is <formula>_<DIM>-<space-group-number> (Cl1Na1_3D-225): the dimensionality tag and group number are joined with - so the last underscore stays the formula boundary, and space-group symbols (P2_1/c, which carry path separators) never appear. Deterministic and filesystem-safe.

Exit codes: 0 = completed, including zero valid structures (an empty fan-out is a valid scientific result, not an error); 2 = bad arguments or an unreadable/malformed composition file; 3 = pyxtal is not importable in this python environment (stderr says exactly what to install); 1 = unexpected error (traceback on stderr).

matmelt.core.stages.structure_runner.DIM_GROUPS: dict[str, tuple[int, int]] = {'1D': (1, 75), '2D': (2, 80), '3D': (3, 230)}#

Dimensionality tag -> (PyXtal dim argument, highest group number in that family). 3D = 230 space groups, 2D = 80 layer groups, 1D = 75 rod groups. Each dimensionality is swept over its own full family (a group number above a family’s count does not exist for it).

matmelt.core.stages.structure_runner.REQUIRED_KEYS = ('formula', 'elements', 'stoichiometry')#

Keys one composition file must carry (the compose stage’s child seed shape).

matmelt.core.stages.structure_runner.load_composition(path)[source]#

Read and validate one composition file.

The same rules StructureStage.composition_of enforces, repeated here because the runner must stay debuggable standalone – it cannot import the stage that normally checks its input.

Parameters:

path (Path) – The composition.json to read.

Returns:

The decoded composition.

Return type:

dict

Raises:

ValueError – Naming the defect: unreadable, not valid JSON, not an object, a missing required key, a formula that is not alphanumeric (it names child directories), or elements and stoichiometry that do not line up.

matmelt.core.stages.structure_runner.child_id(formula, dim_tag, space_group)[source]#

Build the child id for one generated structure.

Parameters:
  • formula (str) – The parent composition’s formula.

  • dim_tag (str) – "3D", "2D" or "1D".

  • space_group (int) – The group number. Group symbols never appear in an id: P2_1/c carries a path separator, and these ids become directory names.

Returns:

<formula>_<dim>-<group>. The dim tag and group number join with - so the last underscore stays the formula boundary.

Return type:

str

Examples

>>> child_id("Cl1Na1", "3D", 225)
'Cl1Na1_3D-225'
class matmelt.core.stages.structure_runner.GenerationReport(children=<factory>, n_rejected=0, n_errors=0, error_examples=<factory>)[source]#

Bases: object

What happened across the swept groups – nothing is silent.

children#

The ids of the structures actually written, in generation order.

Type:

list of str

n_rejected#

Groups where a crystal was generated but its symmetry constraints were not met (crystal.valid false). A normal outcome, not an error.

Type:

int

n_errors#

Groups where PyXtal raised – an incompatible stoichiometry, for instance.

Type:

int

error_examples#

Up to a few example messages, so a systematic failure is visible in the job’s stdout instead of being inferred from a zero count.

Type:

list of str

matmelt.core.stages.structure_runner.generate(composition, out_dir, crystal_factory, dims=('3D',), group_limit=None)[source]#

Try each group of each requested dimensionality once, writing valid crystals.

Parameters:
  • composition (dict[str, Any]) – The validated composition to build structures for.

  • out_dir (Path) – The job’s working directory; children land under children/<child_id>/.

  • crystal_factory (Callable[[], Any]) – Returns a fresh pyxtal-like object per attempt. This is the seam that keeps the loop testable without pyxtal installed.

  • dims (Sequence[str], default: ('3D',)) – Which dimensionalities to sweep.

  • group_limit (tuple[int, int] | None, default: None) – An optional inclusive group range, applied per family. Useful for hand-debugging, where sweeping 230 groups is not the point.

Returns:

The children written plus the counted failures. Each generated structure gets a structure.cif and a structure.json recording its group, lattice type, and volume.

Return type:

GenerationReport

Notes

A failing group never stops the sweep: it is counted, separately for symmetry rejections and raised errors, and the loop continues. The report exists precisely so that no failure is silent.

matmelt.core.stages.structure_runner.write_manifest(children, out_dir)[source]#

Write the fan-out manifest last, atomically.

Parameters:
  • children (list[str]) – The child ids to name, in generation order – ascending dimensionality then group number, which is deterministic by construction.

  • out_dir (Path) – The job’s working directory.

Return type:

None

Notes

The manifest is the driver’s signal that the fan-out result is complete, so it lands only after every child directory exists, via tmp-then-rename. A crash mid-run leaves child directories but no manifest, never a manifest naming children that do not exist.

matmelt.core.stages.structure_runner.main(argv=None)[source]#

Run the structure stage for one composition.

Parameters:

argv (list[str] | None, default: None) – Command-line arguments. None reads sys.argv.

Returns:

The process exit code: 0 when the sweep completed, including a fan-out into zero children, with the per-group tally printed; 2 for a usage error or an unusable composition file; 3 when pyxtal is missing, with the exact pip install command and this interpreter’s path printed to stderr.

Return type:

int

MatMelt screen runner: MACE vc-relax + classify ONE candidate structure.

Self-contained by design – imports stdlib + mace/ase only, never matmelt – because it is staged to the compute backend per job and executed there by the user’s own python; remotes never receive our package code. It is equally runnable by hand for isolated debugging:

python screen_runner.py --cif structure.cif
python screen_runner.py --help

It reads one CIF, runs a variable-cell relaxation (LBFGS over a cell filter – forces and stress minimized together, the vc-relax equivalent), then classifies the result on physical-sanity grounds only:

screen_result.json    # category RELAXED|FAILED + failure_reasons + metrics
relaxed.cif           # written on RELAXED only (a stale one is removed)

Classification is convergence-based (energy window, finite energy, bond overlap, volume change, residual stress, final fmax, energy rise) – NOT a stability threshold: a high-energy metastable structure is information, not a rejection. float64 is the default dtype – required for a reliable stress tensor in vc-relax.

A FAILED category is a normal completed screening (exit 0) – the stage turns it into a terminal science verdict. Exit codes: 0 = screening completed (either category); 2 = bad arguments or unreadable --cif; 3 = mace/ase not importable in this python environment (stderr says exactly what to install); 1 = unexpected error, e.g. a crashed relaxation (traceback on stderr – the stage treats it as a retryable fault, never a science rejection).

matmelt.core.stages.screen_runner.EV_PER_A3_TO_GPA = 160.21766208#

eV/A^3 -> GPa.

matmelt.core.stages.screen_runner.SANITY = {'energy_ceil_eV_atom': 10.0, 'energy_floor_eV_atom': -35.0, 'fmax_acceptance_eV_A': 0.5, 'max_energy_rise_eV_atom': 0.01, 'max_stress_GPa': 2.0, 'max_vol_change_pct': 60.0, 'min_bond_A': 1.2}#

Physical-sanity bounds. Deliberately constants, not CLI knobs (only the model and the main relaxation settings are configurable): they flag MACE extrapolation / unphysical geometries, and loosening them per run would make verdicts incomparable across a database.

matmelt.core.stages.screen_runner.classify(initial, final)[source]#

Classify a finished relaxation as RELAXED or FAILED.

A pure function over plain metric dicts, so every bound is unit-testable with no science stack installed.

Parameters:
  • initial (dict[str, Any]) – Observables before relaxation. Needs e_eV_atom and volume_A3.

  • final (dict[str, Any]) – Observables after relaxation. Needs e_eV_atom, fmax_eV_A and volume_A3; stress_max_GPa and min_bond_A may be None, which skips that check rather than failing the structure – an observable the calculator could not supply is not evidence against a crystal.

Returns:

(category, reasons) where category is "RELAXED" when no check fired and "FAILED" otherwise, and reasons holds one human-readable sentence per failed check.

Return type:

tuple

Notes

The checks are convergence and physical sanity: the energy window, NaN energies, overlapping atoms, runaway volume change, residual stress, the fmax acceptance gate, and an optimizer that walked uphill. Thresholds live in the module’s SANITY constants rather than in CLI options, so verdicts stay comparable across a whole database.

Deliberately absent: any rejection on absolute energy ranking. Metastable is information, not failure.

matmelt.core.stages.screen_runner.write_verdict(verdict, out_dir)[source]#

Write the verdict atomically, scrubbing any stale relaxed structure.

Parameters:
  • verdict (dict[str, Any]) – The document to write.

  • out_dir (Path) – The job’s working directory.

Return type:

None

Notes

A non-RELAXED verdict must never sit next to a leftover relaxed.cif from an earlier attempt, because everything downstream trusts that pairing – so the stale file is removed first.

matmelt.core.stages.screen_runner.screen(sci, args, out_dir)[source]#

Read, variable-cell relax, and classify one structure.

Parameters:
  • sci (dict[str, Any]) – The loaded science symbols.

  • args (Namespace) – Parsed arguments: the input CIF, the model settings, and the convergence targets.

  • out_dir (Path) – The job’s working directory, where relaxed.cif is written on a RELAXED verdict.

Returns:

The verdict document: category, failure reasons, whether the optimizer converged, the step count, the initial and final observables, and the settings used – so a stored verdict is reproducible from its own record.

Return type:

dict

Notes

The relaxation is LBFGS over a cell filter, i.e. atoms and cell relax together. Convergence is not itself the verdict: a run that stops at the step limit can still pass classify’s acceptance gate, and one that converges can still be rejected as unphysical.

matmelt.core.stages.screen_runner.main(argv=None)[source]#

Screen one candidate structure.

Parameters:

argv (list[str] | None, default: None) – Command-line arguments. None reads sys.argv.

Returns:

The process exit code: 0 when screening completed, whichever way the verdict went – a FAILED verdict is a successful screening, and the science lives in the written document; 2 for a usage error or an unreadable structure file; 3 when mace-torch is missing, with the exact pip install command and this interpreter’s path printed to stderr.

Return type:

int

Notes

A crashed relaxation deliberately propagates as an unhandled exception (exit 1 with a traceback). The stage reads that as a retryable fault, which is what keeps an infrastructure failure from ever being recorded as a science rejection.

MatMelt calculate runner: DFTB+ via the user’s own AiiDA, ONE material.

Self-contained by design – imports stdlib + aiida/aiida_mydftbp/ase only, never matmelt – because it is staged to the compute backend per job and executed there by the user’s own python; remotes never receive our package code. It is equally runnable by hand for isolated debugging:

python calculate_runner.py --id Cl1Na1_225 --code-label dftbplus@cluster \
    --skf-prefix /opt/skf/
python calculate_runner.py --help

The user’s environment provides everything AiiDA: aiida-core, the aiida-mydftbp plugin (whose mydftbp.single WorkChain does the self-healing DFTB+ retries itself), a configured profile + code, and a running daemon. This runner is the launcher/extractor around that, per material:

  1. prepare – relaxed.cif -> .gen geometry + dftb_in.hsd from an embedded template (SKF prefix, k-mesh from the options);

  2. ensure the labelled Slater-Koster FolderData exists (find-or-create from --skf-local-path; missing and no local path = loud failure, never a silent stall);

  3. submit one mydftbp.single WorkChain and write calc_submission.json BEFORE polling, so a killed run re-attaches to the same WorkChain instead of resubmitting;

  4. poll to terminal, then extract every healing attempt (parameters with SKF absolute paths stripped, per-attempt diffs) into an atomic calc_result.json.

Exit codes: 0 = calculation completed and calc_result.json written (converged or not – science lives in the JSON, the stage judges); 2 = bad arguments or unreadable --cif; 3 = aiida/aiida_mydftbp/ase not importable in this python environment (stderr says exactly what to install); 1 = unexpected error (traceback on stderr – the stage treats it as a retryable fault).

Every function below takes plain arguments (the AiiDA symbols ride in an api dict built by _load_aiida), so the whole orchestration is testable with fakes and no aiida installed – the dev-venv hard rule.

matmelt.core.stages.calculate_runner.GEOMETRY_NAME = 'geom.out.gen'#

What DFTB+ writes the relaxed geometry to, given the template’s OutputPrefix = "geom.out"; retrieved by the plugin, cached into calc_result.json so exports need no AiiDA round-trip.

matmelt.core.stages.calculate_runner.max_angular_momentum(symbol)[source]#

Give the MaxAngularMomentum shell for one element.

Parameters:

symbol (str) – The element symbol.

Returns:

"s" for the s-block, "d" for the d-block, "f" for the lanthanides and actinides, and "p" for the p-block – which is also the fallback for an unknown symbol, since it is the commonest case and DFTB+ would otherwise refuse to start.

Return type:

str

Examples

>>> max_angular_momentum("Na"), max_angular_momentum("Fe")
('s', 'd')
matmelt.core.stages.calculate_runner.species_from_gen(gen_text)[source]#

Read the element symbols from DFTB+ .gen text.

Parameters:

gen_text (str) – The file’s contents. The species row is line 2 of the header.

Returns:

The symbols, in the order the .gen file indexes them.

Return type:

list of str

Raises:

ValueError – When the text is too short to hold a header, or the species row does not look like element symbols.

matmelt.core.stages.calculate_runner.build_hsd(gen_text, skf_prefix, kpoints)[source]#

Assemble dftb_in.hsd from the embedded template.

Parameters:
  • gen_text (str) – The geometry, embedded inline as GenFormat.

  • skf_prefix (str) – Directory of the .skf files on the machine where DFTB+ runs. Normalized to a trailing slash, because DFTB+ concatenates it with El1-El2.skf verbatim.

  • kpoints (tuple[int, int, int]) – The SupercellFolding k-mesh diagonal.

Returns:

The complete HSD input.

Return type:

str

Notes

MaxAngularMomentum is derived per species. The document’s shape is not arbitrary: it must round-trip through the aiida-mydftbp plugin’s HSD parser, so the keys that plugin’s self-healing ladders tune – MaxSteps, Optimizer, Convergence, SCC, KPointsAndWeights – are all written in the forms it parses explicitly.

matmelt.core.stages.calculate_runner.write_gen(cif_path, gen_path)[source]#

Convert a CIF to a DFTB+ .gen file, in fractional coordinates.

Parameters:
  • cif_path (str | Path) – The structure to read.

  • gen_path (str | Path) – Where to write the .gen file.

Return type:

None

Notes

The runner’s only ase touchpoint besides _load_aiida, imported lazily so everything above stays runnable anywhere. A missing ase has already surfaced as exit 3 before this is ever called.

matmelt.core.stages.calculate_runner.SKF_PATH_REDACTED = '"<stripped>"'#

What replaces the SKF directory in extracted parameters. The only place an absolute machine path leaks into the plugin’s parameter dicts is the SlaterKosterFiles passthrough string under Hamiltonian.DFTB.

matmelt.core.stages.calculate_runner.strip_skf_paths(params)[source]#

Copy params with the SKF directory path redacted.

Parameters:

params (dict[str, Any]) – A parameter dict as the plugin’s HSD parser produced it.

Returns:

A deep copy with the Prefix value replaced. The plugin keeps SlaterKosterFiles as a _raw_N passthrough string inside Hamiltonian.DFTB, and that is the one place an absolute machine path leaks into a record – extracted documents must never carry it. Anything unexpected is left untouched: redaction is best-effort over a copy, never a failure.

Return type:

dict

matmelt.core.stages.calculate_runner.param_diff(old, new, _prefix='')[source]#

Diff two parameter dicts – what the plugin’s healing actually changed.

Parameters:
  • old (dict[str, Any]) – The two parameter dicts to compare.

  • new (dict[str, Any]) – The two parameter dicts to compare.

  • _prefix (str, default: '') – Internal, used to build dotted paths while recursing.

Returns:

{dotted.path: change}, where change is {"from": ..., "to": ...} for a changed value and {"added": ...} or {"removed": ...} for a key present on one side only. Two dict values recurse; a type change compares whole.

Return type:

dict

Notes

Keys starting with _ are skipped at every level: _raw_* passthrough strings and _healing metadata are not parameter changes.

Examples

>>> param_diff({"Driver": {"MaxSteps": 100}}, {"Driver": {"MaxSteps": 500}})
{'Driver.MaxSteps': {'from': 100, 'to': 500}}
matmelt.core.stages.calculate_runner.shape_attempt(pk, attempt, exit_status, params, previous_params, result)[source]#

Shape one WorkChain attempt as a plain JSON-safe dict.

Parameters:
  • pk (int) – The CalcJob’s primary key in the user’s AiiDA database.

  • attempt (int) – Its 1-based position in the healing chain.

  • exit_status (int | None) – How that attempt ended, or None when unavailable.

  • params (dict[str, Any] | None) – The parameters it ran with, or None when unavailable.

  • previous_params (dict[str, Any] | None) – The previous attempt’s parameters, for the diff.

  • result (dict[str, Any] | None) – Its parsed output, or None.

Returns:

The attempt record. healing carries the plugin’s _healing metadata, None when the attempt predates it or parameters are unavailable; params are stored SKF-stripped; and param_changes_from_previous is None for the first attempt and a param_diff otherwise – which is what makes a healing chain readable as “it raised MaxSteps, then loosened convergence”.

Return type:

dict

matmelt.core.stages.calculate_runner.shape_result(material_id, single_wc_pk, uuid, converged, exit_status, attempts, final_geometry=None)[source]#

Assemble the top-level calc_result.json document.

Parameters:
  • material_id (str) – The material this calculation belongs to.

  • single_wc_pk (int) – The mydftbp.single WorkChain’s primary key.

  • uuid (str) – Its uuid, which is what a re-attach verifies against.

  • converged (bool) – Whether the WorkChain reached a converged result.

  • exit_status (int | None) – Its exit status, which the stage maps to a verdict.

  • attempts (list[dict[str, Any]]) – The shaped attempt records, in chronological order.

  • final_geometry (dict[str, Any] | None, default: None) – The geometry DFTB+ actually relaxed to.

Returns:

The result document.

Return type:

dict

Notes

final_result is the last attempt’s parsed output only when the WorkChain converged, and None otherwise: failed attempt history is data too, but only a converged result may be consumed as one.

final_geometry is cached here so consumers never need to reach into the AiiDA repository – the attempts’ own params hold only what each attempt started from. It is captured whether or not the WorkChain converged (the top-level converged flag is what says it may be consumed as a result), and is None when it could not be read.

matmelt.core.stages.calculate_runner.ensure_skf_folder(orm, label, local_path)[source]#

Find the labelled Slater-Koster FolderData, or create it – loudly.

Parameters:
  • orm (Any) – The AiiDA orm module.

  • label (str) – The node label to find or claim.

  • local_path (str | None) – A local directory of .skf files used to create the node when none exists. None means find-only.

Returns:

The stored FolderData node. An existing node is reused, so a second run never duplicates it.

Return type:

Any

Raises:

RuntimeError – When no node carries the label and either no local_path was given or it holds no .skf files. The message names exactly what to configure.

Notes

A missing SKF node is never a quiet skip – that silent stall is the documented defect this function exists to fix. The failure surfaces as a clean exit-1 message rather than a traceback or a run that appears to be working.

matmelt.core.stages.calculate_runner.submit_workchain(api, args, hsd_path)[source]#

Submit one self-healing mydftbp.single WorkChain for this material.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • args (Namespace) – Parsed arguments: the code label, SKF settings, walltime and queue.

  • hsd_path (Path) – The generated dftb_in.hsd, parsed by the plugin into the WorkChain’s initial parameters.

Returns:

The submitted WorkChain node. Submission is non-blocking: the node is handed to the daemon, which is what lets the runner be killed without losing the calculation.

Return type:

Any

Notes

skf_files is passed even though the WorkChain’s default use_remote_skf_path=True means the bytes are never uploaded – it is a required input, and it records provenance.

matmelt.core.stages.calculate_runner.load_submission(out_dir)[source]#

Read the recorded submission, if this material has one.

Parameters:

out_dir (Path) – The job’s working directory.

Returns:

{"pk": ..., "uuid": ...}, or None when no record exists or it is unusable – which the caller treats as “submit a fresh one”. A record that exists but is corrupt warns on stderr first: silently double-submitting a DFTB+ calculation is expensive.

Return type:

dict or None

matmelt.core.stages.calculate_runner.attach_or_submit(api, args, hsd_path)[source]#

Re-attach to the recorded WorkChain, or submit and record a fresh one.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • args (Namespace) – Parsed arguments.

  • hsd_path (Path) – The generated HSD input, used only on a fresh submission.

Returns:

The WorkChain node this run will follow.

Return type:

Any

Notes

calc_submission.json is written immediately after submission, before any polling. That ordering is the whole point: a killed run re-attaches to the same WorkChain on rerun and never double-submits, which was proven live by SIGKILLing the CLI mid-poll and finding exactly one WorkChain in the database afterwards.

A record that no longer loads, or whose uuid disagrees with the loaded node, is treated as stale – warned about, then replaced. A quiet double-submit is never acceptable here.

matmelt.core.stages.calculate_runner.poll_until_terminated(api, pk, interval_s)[source]#

Reload the WorkChain node until it reports terminated.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • pk (int) – The WorkChain’s primary key.

  • interval_s (float) – Seconds to sleep between reloads.

Returns:

The terminated node.

Return type:

Any

Notes

The daemon does the work; this only watches. Killing the runner mid-poll therefore costs nothing – the WorkChain keeps running, and the next run re-attaches to it.

matmelt.core.stages.calculate_runner.extract_attempts(api, workchain)[source]#

Collect every CalcJob attempt under the WorkChain, oldest first.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • workchain (Any) – The terminated WorkChain node.

Returns:

One shaped record per attempt, in chronological order: parameters SKF-stripped, healing metadata captured, and each diffed against the previous attempt that had parameters. This is the full history of what the plugin’s self-healing tried – which MVP_V0’s extractor never pulled.

Return type:

list of dict

Notes

Attempts are found by following CALL_CALC links, since the plugin submits one CalcJob per healing attempt, and sorted by creation time rather than by link order, which is not guaranteed to be chronological.

matmelt.core.stages.calculate_runner.extract_final_geometry(api, workchain, name='geom.out.gen')[source]#

Read the geometry the last attempt relaxed to, from its retrieved folder.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • workchain (Any) – The terminated WorkChain node.

  • name (str, default: 'geom.out.gen') – The retrieved file to read.

Returns:

{"gen": ..., "source_calc_pk": ..., "source": ...}, or None when it could not be read.

Return type:

dict or None

Notes

Best-effort by design. A calculation that converged is not less converged because its geometry could not be read, so every failure here is a stderr warning and a None – never a non-zero exit, and never a lost result document.

Caching this into calc_result.json is what lets matmelt cif export a structure without reaching back into the AiiDA repository at all.

matmelt.core.stages.calculate_runner.run(api, args)[source]#

Drive one material end to end, with the profile already loaded.

Prepare the geometry and HSD input, attach to or submit the WorkChain, poll it to termination, extract every attempt, and write the result atomically.

Parameters:
Returns:

0. The science lives in the written document, not in the exit code: a WorkChain that failed still produced a successful extraction, and the stage reads the verdict out of calc_result.json.

Return type:

int

matmelt.core.stages.calculate_runner.main(argv=None)[source]#

Calculate one screened-in material with DFTB+ through the user’s AiiDA.

Parameters:

argv (list[str] | None, default: None) – Command-line arguments. None reads sys.argv.

Returns:

The process exit code: 0 when a result document was written, whatever the WorkChain’s own outcome; 1 for an expected, actionable failure such as the SKF ensure refusing, printed as a message rather than a traceback; 2 for a usage error or an unreadable structure file; 3 when the AiiDA stack is missing, with the exact install command and this interpreter’s path printed to stderr. Anything unexpected propagates with its traceback.

Return type:

int

MatMelt extract runner: assemble ONE material’s OPTIMADE-shaped record.

Self-contained AND stdlib only – extraction is bookkeeping, not science, so unlike the other runners there is nothing to install and no exit-3 path: it runs under any python, local or remote. It is equally runnable by hand:

python extract_runner.py --id Cl1Na1_225
python extract_runner.py --help

Inputs (all in the cwd, or under --in-dir):

  • relaxed.cif – the screened structure (required); parsed by a minimal built-in CIF reader targeting ASE-written files. Geometry is best-effort: a parse failure degrades to a geometry-less record with a stderr warning – never a crash (composition then comes from the metadata).

  • screen_result.json – must say category: RELAXED; extract serves only screened-in materials (exit 2 otherwise, stated clearly).

  • structure.json – optional provenance from the structure stage (space group, lattice type); folded in when present.

  • calc_result.json – optional DFTB+ outcome from the calculate stage (WorkChain status + the full healing-attempt history, SKF paths already stripped by its runner); folded under _mcli.calculate when present.

Output: record.json (written atomically) – one OPTIMADE structures -shaped document: standard attributes (elements, formulas, sites, lattice) plus the non-standard screening/provenance extras namespaced under _mcli, per the OPTIMADE custom-field convention.

Exit codes: 0 = record written; 2 = bad arguments, a missing or malformed input, a non-RELAXED verdict, or no composition source at all; 1 = unexpected error (traceback on stderr).

matmelt.core.stages.extract_runner.parse_cif(text)[source]#

Parse cell parameters and atom sites out of one CIF document.

A minimal reader targeting the shape ASE writes: scalar _cell_* tags plus one loop_ whose _atom_site_* columns may come in any order. Extra loops are skipped, and the site symbol falls back to _atom_site_label when no type symbol column exists.

Parameters:

text (str) – The CIF document.

Returns:

{"cell": (a, b, c, alpha, beta, gamma), "sites": [(symbol, fx, fy, fz), ...]}.

Return type:

dict

Raises:

ValueError – Naming what is missing – a cell tag, the coordinate columns, a short row, or any atom sites at all. The caller decides whether that is fatal; main degrades loudly rather than failing the record.

matmelt.core.stages.extract_runner.cell_to_lattice(a, b, c, alpha, beta, gamma)[source]#

Convert the six cell parameters into lattice vectors.

Parameters:
  • a (float) – Cell edge lengths, in angstrom.

  • b (float) – Cell edge lengths, in angstrom.

  • c (float) – Cell edge lengths, in angstrom.

  • alpha (float) – Cell angles, in degrees.

  • beta (float) – Cell angles, in degrees.

  • gamma (float) – Cell angles, in degrees.

Returns:

Three lattice vectors (rows) in angstrom, in the standard crystallographic convention: a along x, b in the xy-plane. Right-angle float noise is snapped to honest zeros, since cos(90 deg) is ~6e-17 rather than 0.

Return type:

list

Examples

>>> cell_to_lattice(4.0, 4.0, 4.0, 90.0, 90.0, 90.0)
[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]
matmelt.core.stages.extract_runner.frac_to_cart(frac, lattice)[source]#

Convert fractional coordinates to cartesian ones.

Parameters:
Returns:

The cartesian position, in angstrom.

Return type:

list of float

matmelt.core.stages.extract_runner.chemical_formulas(counts_by_element)[source]#

Build the OPTIMADE chemistry fields for one composition.

Parameters:

counts_by_element (dict[str, int]) – {symbol: count} for the whole structure.

Returns:

The OPTIMADE fields: elements (sorted) and nelements; elements_ratios as fractions of the total, per sorted element; chemical_formula_reduced, gcd-reduced with a count of 1 omitted; chemical_formula_descriptive, the same but keeping every count; and chemical_formula_anonymous, reduced counts descending and lettered A, B, …

Return type:

dict

Raises:

ValueError – When there are no elements – a record with no chemistry serves nobody.

Examples

>>> fields = chemical_formulas({"Na": 4, "Cl": 4})
>>> fields["chemical_formula_reduced"], fields["chemical_formula_anonymous"]
('ClNa', 'AB')
matmelt.core.stages.extract_runner.structure_block(parsed)[source]#

Build the OPTIMADE geometry attributes from a parsed CIF.

Parameters:

parsed (dict[str, Any]) – What parse_cif returned.

Returns:

lattice_vectors, cartesian_site_positions, nsites, species_at_sites, species, and the periodicity fields. Every site is treated as fully occupied by a single element, which is what a generated-and-relaxed structure always is.

Return type:

dict

matmelt.core.stages.extract_runner.build_record(material_id, screen, parsed_cif, meta, calc=None)[source]#

Assemble the OPTIMADE structures-shaped document.

Parameters:
  • material_id (str) – The record’s id.

  • screen (dict[str, Any]) – The screening verdict, whose category, convergence, step count, final observables and settings are carried into the record.

  • parsed_cif (dict[str, Any] | None) – The parsed relaxed structure, or None when the CIF could not be read. Geometry is best-effort: without it the record is still written, with _mcli.geometry_source set to null.

  • meta (dict[str, Any]) – The structure stage’s provenance – space group, dimensionality, lattice type – and the fallback composition source.

  • calc (dict[str, Any] | None, default: None) – The calculate stage’s result document, folded verbatim under _mcli.calculate when given, healing history included. Left None, the record is byte-for-byte what a calculate-less funnel always produced.

Returns:

The complete record, with everything MatMelt-specific namespaced under _mcli per the OPTIMADE custom-field convention.

Return type:

dict

Raises:

ValueError – When neither the CIF nor the metadata can supply a composition.

matmelt.core.stages.extract_runner.write_record(record, out_dir)[source]#

Write record.json atomically.

Parameters:
  • record (dict[str, Any]) – The assembled document.

  • out_dir (Path) – The job’s working directory.

Return type:

None

matmelt.core.stages.extract_runner.main(argv=None)[source]#

Assemble one screened-in material’s record.

Parameters:

argv (list[str] | None, default: None) – Command-line arguments. None reads sys.argv.

Returns:

The process exit code: 0 when the record was written; 2 for a usage error, an unusable screening verdict, a non-RELAXED verdict, or a structure that yields no composition from any source. There is no exit 3 here – the runner is stdlib-only, so it has no dependency to be missing.

Return type:

int

Notes

Geometry is best-effort and degrades loudly: an unparseable CIF prints a warning to stderr and the record is still written from the structure stage’s metadata, with _mcli.geometry_source set to null. The optional structure.json and calc_result.json inputs degrade the same way. Only a total absence of composition is fatal.

MatMelt cif runner: fetch DFTB+ relaxed geometries out of AiiDA (M10).

Self-contained by design – imports stdlib + aiida only, never matmelt – because it is executed by the user’s own python (the one their AiiDA lives in), exactly like the stage runners beside it. It is the one exception to the “one runner per funnel stage” rule: it belongs to the cif command, not to a stage, and is never staged to a compute backend.

Why it exists: calc_result.json records every healing attempt’s input parameters, so the geometry embedded there is what DFTB+ started from (the MACE-relaxed cell from the screen stage). The geometry DFTB+ relaxed to is geom.out.gen in the final CalcJob’s retrieved folder, which lives only in the AiiDA repository. This runner reads it back.

It works in batches – one profile load serves thousands of materials:

python cif_runner.py --batch batch.json --out geometries.json
python cif_runner.py --id Cl1Na1_225 --wc-pk 13524 --uuid abc-123
python cif_runner.py --help

--batch takes a JSON list of {"id", "wc_pk", "uuid"?, "calc_pk"?} entries. The output document is {"results": [{"id", "wc_pk", "calc_pk", "gen", "error", "note"}, ...]} – one row per requested material, in the requested order. A material that cannot be resolved gets an error string and a null geometry: one bad material never costs the batch, and the caller decides what a failure means.

Resolution mirrors the calculate runner’s own extraction: load the WorkChain, verify its uuid when one was recorded (pks are per-profile – this is what stops a profile switch from exporting a stranger’s structure), walk its CALL_CALC CalcJob children, and take the most recent by creation time – the attempt that converged. A recorded calc_pk that disagrees with it is reported in note; the database, not the record, decides.

Exit codes: 0 = the batch ran and the document was written (per-material failures live inside it); 2 = bad arguments or an unreadable/malformed batch file; 3 = aiida not importable in this python environment (stderr says exactly what to install); 1 = the profile could not be loaded or the output could not be written.

matmelt.core.stages.cif_runner.GEOMETRY_NAME = 'geom.out.gen'#

What DFTB+ writes the relaxed geometry to, given our HSD template’s OutputPrefix = "geom.out" – and what the plugin retrieves.

matmelt.core.stages.cif_runner.load_batch(path)[source]#

Read and validate the batch request.

Parameters:

path (str | Path) – The JSON request file the cif command wrote.

Returns:

One normalised entry per material, each with id, wc_pk, uuid and calc_pk keys.

Return type:

list of dict

Raises:

ValueError – Naming the defect: unreadable, not valid JSON, not a list, or an entry without a non-empty string id and an integer wc_pk. uuid and calc_pk are optional.

Notes

Validation happens before AiiDA is touched at all, so a typo in the request costs a message rather than a profile load.

matmelt.core.stages.cif_runner.list_object_names(node)[source]#

List the files stored on a node.

Parameters:

node (Any) – A node with a repository, typically a retrieved FolderData.

Returns:

The stored file names.

Return type:

list of str

matmelt.core.stages.cif_runner.read_object(node, name)[source]#

Read one stored file as text.

Parameters:
  • node (Any) – The node holding it.

  • name (str) – The file to read.

Returns:

Its contents, decoded when stored as bytes.

Return type:

str

Raises:

RuntimeError – When the file is absent. The message lists what is there, which is what turns “no geometry” into a diagnosable answer.

matmelt.core.stages.cif_runner.final_calcjob(api, workchain)[source]#

Find the WorkChain’s most recent CalcJob – its last healing attempt.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • workchain (Any) – The WorkChain to traverse.

Returns:

The newest CALL_CALC CalcJob by creation time.

Return type:

Any

Raises:

RuntimeError – When the WorkChain has no attempts to read a geometry from.

Notes

The same traversal the calculate runner extracts attempts with, so “the final attempt” means the same thing in both documents.

matmelt.core.stages.cif_runner.fetch_geometry(api, entry, geometry_name)[source]#

Fetch one material’s relaxed geometry as a result row. Never raises.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • entry (dict[str, Any]) – One normalised batch entry.

  • geometry_name (str) – The retrieved file to read.

Returns:

A row with id, wc_pk, calc_pk, gen, error and note. Any failure – an unloadable pk, a uuid mismatch, no attempts, no retrieved folder, an absent geometry file – comes back as error text with a null geometry, so the batch always completes and the caller reports per material.

Return type:

dict

Notes

A recorded uuid that disagrees with the loaded node is a refusal, not a warning: exporting a different node’s geometry under this material’s id would silently corrupt the export. A recorded calc_pk that disagrees is only a note, and the database wins.

matmelt.core.stages.cif_runner.run(api, args)[source]#

Fetch every requested geometry and emit the result document.

Parameters:
  • api (dict[str, Any]) – The loaded AiiDA symbols.

  • args (Namespace) – Parsed arguments: a batch file or a single material, plus where to write.

Returns:

0 when the document was emitted, even if some rows carry errors – a per-material failure is data, not a process failure. 1 only when the output file itself could not be written.

Return type:

int

Notes

Progress and the final tally go to stderr, leaving stdout clean for the result document when no --out is given.

matmelt.core.stages.cif_runner.main(argv=None)[source]#

Fetch relaxed DFTB+ geometries out of the user’s AiiDA repository.

Parameters:

argv (list[str] | None, default: None) – Command-line arguments. None reads sys.argv.

Returns:

The process exit code: 0 when the result document was emitted; 1 when the output could not be written; 2 for a usage error or an unusable batch file; 3 when aiida-core is missing, with the exact install command and this interpreter’s path printed to stderr.

Return type:

int