Skip to content

Commit 1760f85

Browse files
committed
Tests: OTF base helpers, cluster interface, end-to-end EMT OTF
Add tests/test_otf/ with three test modules: - test_otf_base.py: backend-agnostic OTF helpers on the base Ensemble (split propagation, setup defaults, update+predict cycle, clean_runs label). Uses Au+EMT with a FLARE SGP. Skip without flare (G9). - test_cluster_interface.py: the layered Cluster/calculator refactor with no flare needed. Covers the DirectCluster generic driver with a mock calculator, the pairing rules (DirectCluster rejects file calculators, Cluster rejects direct ones), QE backward compatibility of prepare_input_file, the EspressoCalculator adapter, the ASE file bridge (prepare_input + local roundtrip), the ASE direct calculator, and the non-picklable calculator early failure. - test_cluster_emt_otf.py: end-to-end OTF on gold+EMT through both DirectCluster and LocalCluster (parametrized), proving the driver is shared. Validates the full chain (calculator -> raw results -> ingestion -> unit conversions) against bare EMT values, and that a second ensemble reuses the trained model with zero new ab-initio calls. Add the 'flare' marker to pytest.ini. Workaround for a flare C extension bug: SGP_Wrapper does not keep a Python reference to the kernel objects (only to the descriptor calculators), so the kernel's pybind11 wrapper is garbage-collected when the helper function returns, leaving the C++ SparseGP with a dangling pointer → segfault in add_training_structure. The tests keep kernels alive in a module-level list (_KERNEL_REFS).
1 parent 9612195 commit 1760f85

5 files changed

Lines changed: 379 additions & 0 deletions

File tree

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
markers =
33
julia: tests requiring the Julia Fourier backend
44
release: long-running tests excluded from normal CI
5+
flare: tests requiring the flare package

tests/test_otf/__init__.py

Whitespace-only changes.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""End-to-end OTF on gold+EMT through DirectCluster and LocalCluster."""
2+
import os
3+
import numpy as np
4+
import pytest
5+
6+
flare = pytest.importorskip("flare") # G9
7+
8+
import cellconstructor as CC
9+
import sscha, sscha.Ensemble
10+
import sscha.Cluster, sscha.LocalCluster, sscha.BaseCluster
11+
import sscha.ClusterCalculators as cluster_calcs
12+
from ase.calculators.emt import EMT
13+
14+
from .test_otf_base import get_gold_dyn, get_sgp_calc_au
15+
16+
17+
def make_direct_cluster(tmp_path, calc, batch_size):
18+
return sscha.BaseCluster.DirectCluster(batch_size=batch_size, job_number=1), \
19+
cluster_calcs.ASEDirectCalculator(EMT())
20+
21+
22+
def make_local_cluster(tmp_path, calc, batch_size):
23+
file_calc = cluster_calcs.ASEFileCalculator(EMT())
24+
cluster = sscha.LocalCluster.LocalCluster("localhost")
25+
cluster.workdir = str(tmp_path / "remote")
26+
cluster.local_workdir = str(tmp_path / "local") + "/"
27+
cluster.submit_command = "bash" # blocking local execution (G8)
28+
cluster.nonblocking_command = False # no squeue polling (G8)
29+
cluster.use_nodes = False
30+
cluster.use_cpu = False
31+
cluster.use_time = False
32+
cluster.use_account = False
33+
cluster.job_number = 1
34+
cluster.batch_size = batch_size # learning-cycle size = batch_size*job_number
35+
cluster.binary = file_calc.command
36+
cluster.mpi_cmd = ""
37+
cluster.setup_workdir()
38+
return cluster, file_calc
39+
40+
41+
@pytest.mark.flare
42+
@pytest.mark.parametrize("backend_factory", [make_direct_cluster, make_local_cluster],
43+
ids=["direct", "localcluster"])
44+
def test_cluster_emt_otf(tmp_path, backend_factory):
45+
np.random.seed(0)
46+
n_configs, batch_size = 8, 2
47+
48+
dyn = get_gold_dyn()
49+
ensemble = sscha.Ensemble.Ensemble(dyn, 300)
50+
ensemble.generate(n_configs)
51+
ensemble.set_otf(get_sgp_calc_au(), std_tolerance_factor=100,
52+
max_atoms_added=-1, update_style="add_n",
53+
update_threshold=None, train_hyps=(1, np.inf),
54+
output_name=str(tmp_path / "otf_run"))
55+
56+
cluster, calc = backend_factory(tmp_path, None, batch_size)
57+
58+
# Reference EMT values for the structures that MUST be computed ab-initio
59+
# (first cycle: model empty -> exactly the first batch_size structures)
60+
ref = []
61+
for i in range(batch_size):
62+
atoms = ensemble.structures[i].get_ase_atoms()
63+
atoms.calc = EMT()
64+
ref.append((atoms.get_potential_energy(), atoms.get_forces().copy()))
65+
66+
ensemble.compute_ensemble(calc, compute_stress=True, cluster=cluster)
67+
68+
# 1. Everything computed (ab-initio or predicted)
69+
assert all(ensemble.force_computed)
70+
assert all(ensemble.stress_computed)
71+
assert np.all(np.isfinite(ensemble.energies))
72+
assert np.all(np.isfinite(ensemble.forces))
73+
assert np.all(np.isfinite(ensemble.stresses))
74+
75+
# 2. The model was trained on the first-cycle structures. With a high
76+
# std_tolerance, only the first structure (empty model -> all init_atoms
77+
# added) necessarily enters the training set; subsequent structures may
78+
# be within bounds and not add new training data.
79+
assert len(ensemble.gp_model.training_data) >= 1
80+
assert os.path.exists(ensemble.flare_name)
81+
82+
# 3. STRONG REGRESSION: the ab-initio structures carry the exact EMT
83+
# values (validates the whole chain: calculator -> raw results ->
84+
# ingestion -> unit conversions). Ensemble units are Ry, Ry/Ang.
85+
from ase.units import create_units
86+
Ry = create_units("2006")["Ry"]
87+
for i in range(batch_size):
88+
assert ensemble.energies[i] == pytest.approx(ref[i][0] / Ry, rel=1e-8)
89+
assert np.allclose(ensemble.forces[i], ref[i][1] / Ry, atol=1e-8)
90+
91+
# 4. A second ensemble reuses the trained model: zero ab-initio calls
92+
np.random.seed(1)
93+
ensemble2 = sscha.Ensemble.Ensemble(dyn, 300)
94+
ensemble2.generate(4)
95+
ensemble2.set_otf(ensemble.flare_calc, std_tolerance_factor=100,
96+
max_atoms_added=-1, update_style="add_n",
97+
update_threshold=None, train_hyps=(1, np.inf),
98+
output_name=str(tmp_path / "otf_run2"))
99+
training_before = len(ensemble2.gp_model.training_data)
100+
ensemble2.compute_ensemble(calc, compute_stress=True, cluster=cluster)
101+
assert all(ensemble2.force_computed)
102+
assert len(ensemble2.gp_model.training_data) == training_before # nothing new learned
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Tests for the layered Cluster/calculator interface (no flare needed)."""
2+
import os
3+
import threading
4+
import subprocess
5+
6+
import numpy as np
7+
import pytest
8+
9+
import cellconstructor as CC
10+
from cellconstructor.calculators import Espresso
11+
from ase.build import bulk
12+
from ase.calculators.emt import EMT
13+
14+
import sscha, sscha.Ensemble
15+
import sscha.Cluster, sscha.LocalCluster, sscha.BaseCluster
16+
import sscha.ClusterCalculators as cluster_calcs
17+
18+
from .test_otf_base import get_gold_dyn # dyn fixture builder (no flare use)
19+
20+
21+
def get_gold_structure():
22+
struct = CC.Structure.Structure()
23+
struct.generate_from_ase_atoms(bulk("Au", "fcc", a=4.0782, cubic=False))
24+
return struct
25+
26+
27+
class MockDirectCalculator(cluster_calcs.DirectCalculator):
28+
"""Deterministic fake: energy = index, zero forces/stress, echo structure."""
29+
def copy(self):
30+
return MockDirectCalculator()
31+
def compute(self, structure):
32+
nat = structure.N_atoms
33+
return {"energy": 1.0, "forces": np.zeros((nat, 3)),
34+
"stress": np.zeros(6), "structure": structure.copy(),
35+
"mock_extra": 42}
36+
37+
38+
def test_directcluster_driver_no_otf():
39+
"""The generic driver fills the ensemble through a DirectCalculator."""
40+
np.random.seed(0)
41+
ensemble = sscha.Ensemble.Ensemble(get_gold_dyn(), 0)
42+
ensemble.generate(4)
43+
cluster = sscha.BaseCluster.DirectCluster(batch_size=2, job_number=1)
44+
ensemble.compute_ensemble(MockDirectCalculator(), compute_stress=True,
45+
cluster=cluster)
46+
assert all(ensemble.force_computed)
47+
assert all(ensemble.stress_computed)
48+
assert np.allclose(ensemble.energies, 1.0 / 13.605698066) # eV -> Ry
49+
assert all(p["mock_extra"] == 42 for p in ensemble.all_properties)
50+
51+
52+
def test_pairing_rules():
53+
"""DirectCluster rejects file calculators; Cluster rejects direct ones."""
54+
cluster = sscha.BaseCluster.DirectCluster()
55+
with pytest.raises(TypeError, match="cannot be used"):
56+
cluster._check_calculator_interface(Espresso(
57+
input_data={"control": {}, "system": {}}, pseudopotentials={"Au": "Au.upf"}))
58+
59+
remote = sscha.Cluster.Cluster(hostname="localhost")
60+
with pytest.raises(TypeError, match="cannot be used"):
61+
remote._check_calculator_interface(MockDirectCalculator())
62+
63+
# correct pairings pass
64+
cluster._check_calculator_interface(MockDirectCalculator())
65+
remote._check_calculator_interface(Espresso(
66+
input_data={"control": {}, "system": {}}, pseudopotentials={"Au": "Au.upf"}))
67+
68+
69+
def test_espresso_prepare_input_file_backward_compatible(tmp_path):
70+
"""Phase 3.2 regression: QE calculators must behave exactly as before."""
71+
cluster = sscha.LocalCluster.LocalCluster("localhost")
72+
cluster.local_workdir = str(tmp_path) + "/"
73+
cluster.lock = threading.Lock()
74+
calc = Espresso(
75+
input_data={
76+
"control": {"tprnfor": True, "tstress": True},
77+
"system": {"ecutwfc": 30, "ecutrho": 240, "occupations": "fixed"},
78+
"electrons": {"conv_thr": 1e-8},
79+
},
80+
pseudopotentials={"Au": "Au.upf"},
81+
kpts=(1, 1, 1),
82+
)
83+
inputs, outputs = cluster.prepare_input_file([get_gold_structure()], calc, ["ESP_0"])
84+
assert inputs == ["ESP_0.pwi"]
85+
assert outputs == ["ESP_0.pwo"]
86+
assert os.path.exists(tmp_path / "ESP_0.pwi")
87+
88+
89+
def test_espresso_calculator_adapter():
90+
"""The espresso-specific calculator keeps class through copy()."""
91+
calc = cluster_calcs.EspressoCalculator(
92+
input_data={"control": {}, "system": {}}, pseudopotentials={"Au": "Au.upf"})
93+
assert calc.input_extension == ".pwi"
94+
assert calc.output_extension == ".pwo"
95+
assert "PREFIX.pwi" in calc.get_execution_command()
96+
assert isinstance(calc.copy(), cluster_calcs.EspressoCalculator)
97+
98+
99+
def test_ase_file_calculator_prepare_input(tmp_path):
100+
"""The ASE file bridge uses its own extensions and ships calculator.pkl."""
101+
cluster = sscha.LocalCluster.LocalCluster("localhost")
102+
cluster.local_workdir = str(tmp_path) + "/"
103+
cluster.lock = threading.Lock()
104+
calc = cluster_calcs.ASEFileCalculator(EMT())
105+
inputs, outputs = cluster.prepare_input_file([get_gold_structure()], calc, ["ESP_0"])
106+
assert inputs == ["ESP_0_input.json", "calculator.pkl"]
107+
assert outputs == ["ESP_0.json"]
108+
assert os.path.exists(tmp_path / "ESP_0_input.json")
109+
assert os.path.exists(tmp_path / "calculator.pkl")
110+
111+
112+
def test_ase_file_calculator_local_roundtrip(tmp_path):
113+
"""Run locally exactly what the cluster would run; compare with bare EMT."""
114+
calc = cluster_calcs.ASEFileCalculator(EMT())
115+
calc.set_directory(str(tmp_path))
116+
calc.set_label("ESP_0")
117+
struct = get_gold_structure()
118+
calc.write_input(struct)
119+
120+
# Execute the runner exactly as Cluster.get_execution_command would
121+
cmd = calc.command.replace("PREFIX", os.path.join(str(tmp_path), "ESP_0"))
122+
subprocess.run(cmd, shell=True, check=True, cwd=str(tmp_path))
123+
124+
calc.read_results()
125+
126+
atoms = struct.get_ase_atoms()
127+
atoms.calc = EMT()
128+
assert calc.results["energy"] == pytest.approx(atoms.get_potential_energy(), rel=1e-10)
129+
assert np.allclose(calc.results["forces"], atoms.get_forces(), atol=1e-10)
130+
assert np.allclose(calc.results["stress"], atoms.get_stress(), atol=1e-10)
131+
assert np.allclose(calc.structure.coords, struct.coords, atol=1e-10)
132+
133+
134+
def test_ase_direct_calculator_matches_bare_ase():
135+
calc = cluster_calcs.ASEDirectCalculator(EMT())
136+
struct = get_gold_structure()
137+
res = calc.compute(struct)
138+
atoms = struct.get_ase_atoms()
139+
atoms.calc = EMT()
140+
assert res["energy"] == pytest.approx(atoms.get_potential_energy(), rel=1e-12)
141+
assert np.allclose(res["forces"], atoms.get_forces(), atol=1e-12)
142+
assert np.allclose(res["stress"], atoms.get_stress(), atol=1e-12)
143+
# per-thread copies are independent
144+
assert calc.copy().ase_calc is not calc.ase_calc
145+
146+
147+
def test_nonpicklable_calculator_fails_early():
148+
class Unpicklable:
149+
def __getstate__(self):
150+
raise TypeError("no pickle")
151+
with pytest.raises(TypeError):
152+
cluster_calcs.ASEFileCalculator(Unpicklable())

tests/test_otf/test_otf_base.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Tests for the backend-agnostic OTF helpers on the base Ensemble class.
2+
3+
.. note:: The ensemble is built inside each test (not in a pytest fixture)
4+
because the flare C extension segfaults when SGP objects are created in
5+
a fixture and later used in the test body (a known flare/pytest
6+
interaction bug). Creating the objects inside the test function works
7+
reliably.
8+
"""
9+
import os
10+
import numpy as np
11+
import pytest
12+
13+
flare = pytest.importorskip("flare") # G9: skip the whole module without flare
14+
15+
from flare.bffs.sgp.calculator import SGP_Calculator
16+
17+
import cellconstructor as CC, cellconstructor.Phonons
18+
import sscha, sscha.Ensemble
19+
from ase.calculators.emt import EMT
20+
from ase.build import bulk
21+
22+
23+
def get_gold_dyn(supercell=(2, 2, 2)):
24+
"""Harmonic Au (EMT), 8 atoms with the default supercell."""
25+
struct = CC.Structure.Structure()
26+
struct.generate_from_ase_atoms(bulk("Au", "fcc", a=4.0782, cubic=False))
27+
dyn = CC.Phonons.compute_phonons_finite_displacements(struct, EMT(), supercell=supercell)
28+
dyn.Symmetrize()
29+
dyn.ForcePositiveDefinite()
30+
return dyn
31+
32+
33+
# Keep kernel objects alive for the lifetime of the process: SGP_Wrapper does
34+
# not store a Python reference to the kernels (only to the descriptor
35+
# calculators), so without this the pybind11 wrapper is garbage-collected when
36+
# the helper returns, leaving the C++ SparseGP with a dangling pointer →
37+
# segfault in add_training_structure.
38+
_KERNEL_REFS = []
39+
40+
41+
def get_sgp_calc_au():
42+
"""Empty SGP calculator for gold."""
43+
from flare.bffs.sgp._C_flare import NormalizedDotProduct, B2
44+
from flare.bffs.sgp import SGP_Wrapper
45+
cutoff = 4.0
46+
kernel = NormalizedDotProduct(2.0, 2)
47+
b2 = B2("chebyshev", "quadratic", [0.0, cutoff], [], [1, 4, 3], cutoff * np.ones((1, 1)))
48+
sgp = SGP_Wrapper([kernel], [b2], cutoff, 0.1, 0.1, 0.1, {79: 0},
49+
single_atom_energies={0: 0.0}, variance_type="local",
50+
opt_method="L-BFGS-B", max_iterations=5)
51+
_KERNEL_REFS.append(kernel) # flare bug workaround
52+
return SGP_Calculator(sgp)
53+
54+
55+
def _make_ensemble(tmp_path, n_configs=8):
56+
"""Build an OTF ensemble for gold+EMT (call inside the test, not a fixture)."""
57+
np.random.seed(0)
58+
ens = sscha.Ensemble.Ensemble(get_gold_dyn(), 300)
59+
ens.generate(n_configs)
60+
ens.set_otf(get_sgp_calc_au(), std_tolerance_factor=100,
61+
max_atoms_added=-1, update_style="add_n", update_threshold=None,
62+
train_hyps=(1, np.inf), output_name=str(tmp_path / "otf_run"))
63+
return ens
64+
65+
66+
def test_split_propagates_otf_state(tmp_path):
67+
"""Phase 2 regression: get_noncomputed() must carry the OTF state."""
68+
ensemble = _make_ensemble(tmp_path)
69+
sub = ensemble.get_noncomputed()
70+
assert sub.gp_model is ensemble.gp_model
71+
assert sub.flare_calc is ensemble.flare_calc
72+
assert sub.std_tolerance == ensemble.std_tolerance
73+
assert sub.max_atoms_added == ensemble.max_atoms_added
74+
assert sub.update_style == ensemble.update_style
75+
assert sub.train_hyps == ensemble.train_hyps
76+
assert sub.output is ensemble.output
77+
assert sub.flare_name == ensemble.flare_name
78+
79+
80+
def test_otf_setup_defaults(tmp_path):
81+
ensemble = _make_ensemble(tmp_path)
82+
ensemble._otf_setup_defaults(8)
83+
assert ensemble.max_atoms_added == 8
84+
assert ensemble.init_atoms == list(range(8))
85+
86+
87+
def test_update_and_predict_cycle(tmp_path):
88+
"""update GP with one EMT structure -> train -> predict the rest."""
89+
ensemble = _make_ensemble(tmp_path)
90+
ensemble._otf_setup_defaults(8)
91+
92+
# empty model: nothing is predicted
93+
remaining = list(range(ensemble.N))
94+
ensemble._otf_predict(ensemble.structures, remaining)
95+
assert remaining == list(range(ensemble.N))
96+
97+
# update with one EMT reference (empty model -> all init_atoms added)
98+
struct = ensemble.structures[0]
99+
atoms = struct.get_ase_atoms()
100+
atoms.calc = EMT()
101+
ensemble._otf_update_from_structure(
102+
struct, atoms.get_potential_energy(), atoms.get_forces(),
103+
atoms.get_stress())
104+
assert len(ensemble.gp_model.training_data) >= 1
105+
106+
ensemble._otf_maybe_train_and_write(dft_counts=1)
107+
assert os.path.exists(ensemble.flare_name)
108+
109+
# trained model with huge tolerance: everything predicted
110+
remaining = list(range(ensemble.N))
111+
ensemble._otf_predict(ensemble.structures, remaining)
112+
assert remaining == []
113+
assert all(ensemble.force_computed)
114+
assert np.all(np.isfinite(ensemble.energies))
115+
assert np.all(np.isfinite(ensemble.forces))
116+
117+
118+
def test_clean_runs_generic_label(tmp_path, capsys):
119+
ensemble = _make_ensemble(tmp_path)
120+
ensemble.force_computed[:] = True
121+
ensemble._clean_runs(dft_counts=2)
122+
out, _ = capsys.readouterr()
123+
assert "SUMMARY CALCULATIONS" in out
124+
assert "Steps using OTF-ML model :" in out

0 commit comments

Comments
 (0)