Skip to content

Worked example

A realistic refinement of an amorphous framework material (a-ZIF-like: C, H, N, Zn) against three datasets simultaneously — X-ray \(F_X(Q)\), neutron \(F_N(Q)\), and neutron \(G_N(r)\) — with an ACE potential, molecule moves, and volume moves at ambient pressure. This is a trimmed-down version of a production script; every piece is optional, and the quickstart scaffold underneath is unchanged.

Setup

from mosaic_materials.engine import MCEngine
from mosaic_materials.constraints import (
    EnthalpyConstraint,
    RDFConstraint,
    XrayStructureFactorConstraint,
    NeutronStructureFactorConstraint,
    NeutronTotalRDFConstraint,
)
from mosaic_materials.data import ScatteringData
from mosaic_materials.monte_carlo.adapt import MoveSpec
from mosaic_materials.monte_carlo.callbacks import (
    summary_logger,
    move_magnitude_tuning_logger,
)
from mosaic_materials.monte_carlo.driver import MCDriver
from mosaic_materials.monte_carlo.schedules import ConstantSchedule
from mosaic_materials.moves import (
    TranslateMove,
    TranslateRotateMove,
    VolumeMove,
)

TEMPERATURE = 593.15   # K
DEUTERATION = 5.6 / 6  # fraction of exchangeable H that is D

engine = MCEngine(cores=32, seed=42)
engine.load_lammps_data("azif_4x4x4.data")
engine.add_force_field(
    "pace recursive", "* * zif-ace-24.yace C H N Zn"
)

Constraints

The energy constraint and the RDF come first; the RDF's r_max sets the real-space window every scattering constraint sees, so it is reused for the finite-box correction of the reciprocal-space data.

engine.add_constraint(EnthalpyConstraint(temperature_Kelvin=TEMPERATURE))

r_max = min(engine.system.cell_lengths) / 2 - 1.0
engine.add_constraint(RDFConstraint(r_max=r_max))

Each dataset becomes one constraint. sigmas weights the per-point χ² (smaller σ → stronger constraint); the neutron constraints take the deuteration ratio, which mixes the ¹H and ²H coherent scattering lengths (see Total scattering).

xfq = ScatteringData.from_file("azif.xfq", sigmas=0.01)
xfq.apply_finite_box_correction(r_max=r_max, inplace=True)
engine.add_constraint(
    XrayStructureFactorConstraint.from_scattering_data("c_x_fq", xfq)
)

nfq = ScatteringData.from_file("azif.nfq", sigmas=0.01)
nfq.apply_finite_box_correction(r_max=r_max, inplace=True)
engine.add_constraint(
    NeutronStructureFactorConstraint.from_scattering_data(
        "c_n_fq", nfq, deuteration_ratio=DEUTERATION
    )
)

ngr = ScatteringData.from_file("azif.ngr", sigmas=0.03)
engine.add_constraint(
    NeutronTotalRDFConstraint.from_scattering_data(
        "c_n_gr", ngr, deuteration_ratio=DEUTERATION
    )
)

X-ray scale

XrayStructureFactorConstraint applies an analytical scale minimisation by default (minimise_differences=True), since X-ray patterns often carry an arbitrary overall scale. The neutron constraints compare absolutely by default.

Moves

Three move types, each with adaptive amplitude tuning: every interval trials the tuned parameters are nudged towards target_acceptance, within [min_values, max_values]. Selectors decide what each move acts on: a random atom, a whole random molecule (the mol-ids in the data file), or nothing (volume moves act on the box).

move_specs = [
    MoveSpec(
        name="translate",
        move_class=TranslateMove,
        selector=lambda system, rng: [system.pick_random_atom(rng)],
        kwargs={"max_disp": 0.1},
        target_acceptance=0.3,
        adjustment_factor=1.05,
        interval=200,
        min_values={"max_disp": 0.01},
        max_values={"max_disp": 0.50},
    ),
    MoveSpec(
        name="translate_rotate",
        move_class=TranslateRotateMove,
        selector=lambda system, rng: system.pick_random_molecule(rng)[1],
        kwargs={"max_disp": 0.1, "max_angle": 2.5, "pivot": "com"},
        target_acceptance=0.3,
        adjustment_factor=1.05,
        interval=200,
        min_values={"max_disp": 0.01, "max_angle": 0.1},
        max_values={"max_disp": 0.50, "max_angle": 3.0},
    ),
    MoveSpec(
        name="volume",
        move_class=VolumeMove,
        selector=lambda system, rng: [],
        kwargs={"max_dlnv": 1e-4, "rigid_molecules": True},
        target_acceptance=0.3,
        adjustment_factor=1.05,
        interval=200,
        min_values={"max_dlnv": 1e-5},
        max_values={"max_dlnv": 1e-3},
    ),
]

rigid_molecules=True scales molecular centres of mass with the box and keeps intramolecular geometry fixed — the acceptance rule then counts molecules, not atoms, in its volume-entropy term (see Theory).

Driver, logging, and the run loop

Volume moves sample the isothermal–isobaric ensemble, so a pressure schedule is supplied (in GPa; ambient ≈ 0).

driver = MCDriver(
    engine,
    move_specs,
    temperature_schedule=ConstantSchedule(TEMPERATURE),
    pressure_schedule=ConstantSchedule(0.0),
    callbacks=[
        summary_logger("summary.csv", interval=100),
        move_magnitude_tuning_logger(move_specs),
    ],
)

for step, move, accepted, chi2, U, V in driver.run(1_000_000):
    if step % 10_000 == 0:
        atoms = engine.to_ase_atoms()
        atoms.info.update(step=step, chi2=chi2, U=U)
        atoms.write("snapshots.xyz", format="extxyz", append=True)

engine.lmp.command("write_data final.data")

summary_logger appends a CSV row every 100 steps (step, temperature, pressure, volume, acceptance counts, per-constraint costs, timing); move_magnitude_tuning_logger logs every amplitude adjustment.

Inspecting the fit

The driver keeps the last accepted SimulationState, which caches every constraint's computed pattern under its label:

import matplotlib.pyplot as plt

ax = driver.state.plot_scattering("c_n_fq", x=nfq.x, y=nfq.y)
ax.set_xlabel(r"$Q$ (Å$^{-1}$)")
ax.set_ylabel(r"$F_{\mathrm{N}}(Q)$")
plt.savefig("fit_nfq.pdf")

Restarting

driver.run(n_steps, start=...) offsets the reported step and the schedules, so a continuation behaves as one long run:

for step, *rest in driver.run(500_000, start=1_000_000):
    ...

Reload the structure from final.data (or the last snapshot) when constructing the engine, and re-use the same constraint and move setup.

Finally, close the engine (or construct it in a with block, as in the quickstart):

engine.close()