# -*- coding: utf-8 -*-
"""Pure-function builders for the Golden Test step's metrics JSON.
These functions take a `molsystem.Configuration` and return JSON-serializable
dicts. They have no side effects, do not write to disk, and have no SEAMM
dependencies beyond ``molsystem`` and RDKit, so they can be unit tested
without instantiating a flowchart.
The schema produced here is described in NOTES_golden_tests_design.rst.
Bump ``SCHEMA_VERSION`` when making a backwards-incompatible change to the
shape of the output.
"""
from collections import defaultdict
from rdkit import Chem
from rdkit.Chem import Descriptors, rdMolDescriptors
SCHEMA_VERSION = 1
# Avogadro's number (CODATA 2018, exact by definition since the 2019 SI
# redefinition). 1/mol.
N_A = 6.02214076e23
[docs]
def build_metrics(configuration, step_name="golden", schema_version=SCHEMA_VERSION):
"""Build a metrics dict from a molsystem Configuration.
Parameters
----------
configuration : molsystem.Configuration
The current system/configuration.
step_name : str
Name of the step producing these metrics. Recorded in the output as
the ``step`` field. The Golden Test step itself passes ``"golden"``;
a hypothetical plug-in-internal use could pass its own name.
schema_version : int
Schema version to tag the output with. Callers normally use the
default; the parameter exists so tests can pin a version.
Returns
-------
dict
A JSON-serializable dict with keys ``schema_version``, ``step``,
``system``, ``components``, ``derived``.
"""
system = _build_system(configuration)
components = _build_components(configuration)
derived = _build_derived(system, components)
return {
"schema_version": schema_version,
"step": step_name,
"system": system,
"components": components,
"derived": derived,
}
def _build_system(configuration):
"""Build the ``system`` block: aggregate properties of the whole cell."""
periodicity = configuration.periodicity
system = {
"periodic": periodicity > 0,
"dimensionality": periodicity,
"n_atoms": configuration.n_atoms,
"n_bonds": configuration.n_bonds,
"charge": configuration.charge,
"spin_multiplicity": configuration.spin_multiplicity,
}
if periodicity == 3:
cell = configuration.cell
a, b, c, alpha, beta, gamma = cell.parameters
system["cell"] = {
"a": a,
"b": b,
"c": c,
"alpha": alpha,
"beta": beta,
"gamma": gamma,
"volume": cell.volume,
"lengths_units": "Å",
"angles_units": "degree",
"volume_units": "Å^3",
}
elif periodicity == 0:
# Non-periodic: use a bounding box. Note that this is not a
# rotation-invariant descriptor; for golden testing of clusters it
# should be compared with a loose tolerance (or omitted from
# golden_expected.json entirely).
xyz = configuration.atoms.coordinates
if xyz:
xs = [c[0] for c in xyz]
ys = [c[1] for c in xyz]
zs = [c[2] for c in xyz]
system["bounding_box"] = {
"a": max(xs) - min(xs),
"b": max(ys) - min(ys),
"c": max(zs) - min(zs),
"units": "Å",
}
# periodicity 1 or 2: not handled in v1. Cell is partially defined and
# would need its own representation. Leave 'cell' absent for now.
return system
def _build_components(configuration):
"""Build the ``components`` list: one entry per chemically distinct molecule.
The configuration is converted to a single RDKit molecule and split into
its connected fragments. Each fragment is canonicalized to its isomeric
canonical SMILES. Fragments with identical SMILES are grouped, and the
component's ``count`` is the size of the group.
Mass and formula are computed by RDKit on a representative fragment from
each group. They are therefore based on RDKit's standard atomic weights,
which may differ in the 4th or 5th decimal from molsystem's; for golden
testing this is well below the default tolerance.
"""
rdk_mol = configuration.to_RDKMol()
# Split the configuration into connected molecules. asMols=True returns
# a tuple of Mol objects, one per connected fragment.
frags = Chem.GetMolFrags(rdk_mol, asMols=True)
smiles_groups = defaultdict(list)
for frag in frags:
smi = Chem.MolToSmiles(frag, isomericSmiles=True, canonical=True)
smiles_groups[smi].append(frag)
components = []
for smi, group in smiles_groups.items():
representative = group[0]
formula = rdMolDescriptors.CalcMolFormula(representative)
mass = Descriptors.MolWt(representative)
components.append(
{
"smiles": smi,
"formula": formula,
"count": len(group),
"mass_g_per_mol": mass,
}
)
# Sort for stable, human-friendly output. The comparator matches by
# SMILES, so order does not affect verification.
components.sort(key=lambda c: (-c["count"], c["smiles"]))
return components
def _build_derived(system, components):
"""Build the ``derived`` block: aggregate quantities computed from the above.
Fields are omitted (rather than set to ``null``) when they are not
applicable: density and molarities require a periodic cell with a
defined volume; fractions require at least one component with mass.
"""
derived = {}
if not components:
return derived
counts = [c["count"] for c in components]
masses = [c["mass_g_per_mol"] for c in components]
# Total mass: sum over components of count * mass_per_molecule (g/mol-cell)
total_mass = sum(n * m for n, m in zip(counts, masses))
derived["total_mass_g_per_mol"] = total_mass
# Composition ratios. These are well-defined as long as the cell is
# non-empty (handled by the early return above).
min_count = min(counts)
derived["molar_ratios"] = [n / min_count for n in counts]
total_count = sum(counts)
if total_count > 0:
derived["mole_fractions"] = [n / total_count for n in counts]
if total_mass > 0:
derived["wt_fractions"] = [
(counts[i] * masses[i]) / total_mass for i in range(len(components))
]
# Density and molarities only make sense in a periodic cell. For
# density: mass of the cell in grams divided by volume in mL.
# The cell contains exactly one copy of each count, so cell mass in
# grams is total_mass[g/mol] / N_A.
if system.get("periodic") and "cell" in system:
volume_A3 = system["cell"]["volume"]
if volume_A3 > 0 and total_mass > 0:
cell_mass_g = total_mass / N_A
volume_mL = volume_A3 * 1.0e-24 # 1 Å^3 = 1e-24 mL
derived["density_g_per_mL"] = cell_mass_g / volume_mL
volume_L = volume_A3 * 1.0e-27 # 1 Å^3 = 1e-27 L
derived["molarities_M"] = [n / N_A / volume_L for n in counts]
return derived