Skip to content

Commit c6c29d7

Browse files
committed
Phase 1+2: hoist FLARE OTF helpers to Ensemble, propagate OTF state in split()
Move the backend-agnostic FLARE on-the-fly helpers (_predict_with_model, _compute_properties, _write_model, _update_gp, _train_gp, _clean_runs) from AiiDAEnsemble up to the base Ensemble class, so any cluster backend can use them. Add _otf_setup_defaults, _otf_predict, _otf_update_from_structure and _otf_maybe_train_and_write as the public entry points the generic cluster driver will call, plus a _import_flare_learners() lazy importer that keeps flare optional (G9). Use Rydberg/Bohr from the Ensemble module units (G4) and add the 'deepcopy' import (G5). Propagate the 16 OTF state attributes through Ensemble.split() via the new OTF_STATE_ATTRIBUTES class constant. Without this, the sub-ensemble built by compute_ensemble via get_noncomputed() would have gp_model=None and OTF would be silently disabled on any backend going through the compute_ensemble dispatcher (G1). The AiiDA path never splits with OTF active, so this is behavior-preserving.
1 parent 25a0452 commit c6c29d7

1 file changed

Lines changed: 301 additions & 0 deletions

File tree

Modules/Ensemble.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import print_function, annotations
33
import sys, os
44
import warnings
5+
from copy import deepcopy
56
import numpy as np
67
import time
78
#from scipy.special import tanh, sinh, cosh
@@ -122,6 +123,29 @@ def Main(self):
122123

123124
__DEBUG_RHO__ = False
124125

126+
127+
def _import_flare_learners():
128+
"""Lazy import of the FLARE objects used by the on-the-fly helpers.
129+
130+
Returns
131+
-------
132+
(FLARE_Atoms, compute_mae, get_env_indices, is_std_in_bound)
133+
134+
Raises
135+
------
136+
ImportError: with an actionable message if flare is not installed.
137+
"""
138+
try:
139+
from flare.atoms import FLARE_Atoms
140+
from flare.io.output import compute_mae
141+
from flare.learners.utils import get_env_indices, is_std_in_bound
142+
except ImportError as exc:
143+
raise ImportError(
144+
"On-the-fly learning requires the 'flare' package "
145+
"(https://github.com/mir-group/flare). Original error: {}".format(exc)
146+
) from exc
147+
return FLARE_Atoms, compute_mae, get_env_indices, is_std_in_bound
148+
125149
"""
126150
This source contains the Ensemble class
127151
It is used to Load and Save info about the ensemble.
@@ -3962,6 +3986,14 @@ def split(self, split_mask):
39623986

39633987
ens.all_properties = [self.all_properties[x] for x in np.arange(len(split_mask))[split_mask]]
39643988

3989+
# Propagate the on-the-fly (FLARE) state. Without this, the
3990+
# `computing_ensemble` built by compute_ensemble via get_noncomputed()
3991+
# would have gp_model=None and OTF would be silently disabled
3992+
# on any backend that goes through the compute_ensemble dispatcher
3993+
# (i.e. every Cluster flavor and plain ASE calculators).
3994+
for attr in self.OTF_STATE_ATTRIBUTES:
3995+
setattr(ens, attr, getattr(self, attr))
3996+
39653997
return ens
39663998

39673999

@@ -4295,6 +4327,275 @@ def set_otf(
42954327
train_hyps[1] = np.inf
42964328
self.train_hyps = train_hyps
42974329

4330+
# ------------------------------------------------------------------ #
4331+
# On-the-fly (FLARE) helpers — backend-agnostic, shared by all #
4332+
# clusters (BaseCluster driver) and the AiiDAEnsemble path. #
4333+
# ------------------------------------------------------------------ #
4334+
4335+
OTF_STATE_ATTRIBUTES = (
4336+
"gp_model", "flare_calc", "std_tolerance", "max_atoms_added",
4337+
"update_style", "update_threshold", "build_mode", "output",
4338+
"output_name", "checkpt_name", "flare_name", "atoms_name",
4339+
"checkpt_files", "write_model", "init_atoms", "train_hyps",
4340+
)
4341+
4342+
def _otf_setup_defaults(self, number_of_atoms):
4343+
"""Resolve the OTF settings that depend on the system size.
4344+
4345+
Extracted verbatim from AiiDAEnsemble.compute_ensemble.
4346+
"""
4347+
if self.max_atoms_added < 0:
4348+
self.max_atoms_added = number_of_atoms
4349+
if self.init_atoms is None:
4350+
self.init_atoms = list(range(number_of_atoms))
4351+
4352+
def _otf_predict(self, structures, candidate_indices):
4353+
"""Predict with the ML model the structures within uncertainty.
4354+
4355+
No-op while the model is empty (first ab-initio batch), mirroring
4356+
the guard in AiiDAEnsemble.compute_ensemble.
4357+
"""
4358+
if len(self.gp_model.training_data) > 0:
4359+
self._predict_with_model(structures, candidate_indices)
4360+
4361+
def _otf_update_from_structure(self, structure, dft_energy, dft_frcs,
4362+
dft_stress=None):
4363+
"""Update the GP from a cellconstructor Structure and plain-eV data.
4364+
4365+
Backend-agnostic wrapper around _update_gp: callers (e.g. Cluster)
4366+
do not need to import flare at all.
4367+
4368+
Args:
4369+
----
4370+
structure: cellconstructor.Structure.Structure that was computed.
4371+
dft_energy: total energy in eV.
4372+
dft_frcs: forces, shape (nat, 3), in eV/Angstrom.
4373+
dft_stress: stress in ASE convention and Voigt order
4374+
(xx, yy, zz, yz, xz, xy), in eV/Angstrom^3, or None.
4375+
"""
4376+
FLARE_Atoms, _, _, _ = _import_flare_learners()
4377+
self._update_gp(
4378+
FLARE_Atoms.from_ase_atoms(structure.get_ase_atoms()),
4379+
dft_frcs=dft_frcs,
4380+
dft_energy=dft_energy,
4381+
dft_stress=dft_stress,
4382+
)
4383+
4384+
def _otf_maybe_train_and_write(self, dft_counts):
4385+
"""Train the hyperparameters (inside the train_hyps window) and dump.
4386+
4387+
Exactly the 'TRAIN SECTION' of AiiDAEnsemble.compute_ensemble.
4388+
"""
4389+
if dft_counts > 0:
4390+
if self.train_hyps[0] <= len(self.gp_model.training_data) <= self.train_hyps[1]:
4391+
self._train_gp()
4392+
self._write_model()
4393+
4394+
def _predict_with_model(self, structures, candidate_indices):
4395+
"""Predict on all the structures and estimate errors.
4396+
4397+
Structures whose predicted uncertainty is acceptable are removed from
4398+
`candidate_indices` (in place) and filled with the ML prediction.
4399+
4400+
Args:
4401+
----
4402+
structures: list of cellconstructor.Structure.Structure
4403+
candidate_indices: indices of the structures still to be computed
4404+
ab-initio; modified in place.
4405+
"""
4406+
FLARE_Atoms, _, get_env_indices, is_std_in_bound = _import_flare_learners()
4407+
4408+
sub_indices = deepcopy(candidate_indices)
4409+
4410+
for index in sub_indices:
4411+
structure = structures[index]
4412+
atoms = FLARE_Atoms.from_ase_atoms(structure.get_ase_atoms())
4413+
self._compute_properties(atoms)
4414+
4415+
# get max uncertainty atoms
4416+
if self.build_mode == 'bayesian':
4417+
env_selection = is_std_in_bound
4418+
elif self.build_mode == 'direct':
4419+
env_selection = get_env_indices
4420+
4421+
tic = time.time()
4422+
4423+
std_in_bound, _ = env_selection(
4424+
self.std_tolerance,
4425+
self.gp_model.force_noise,
4426+
atoms,
4427+
max_atoms_added=self.max_atoms_added,
4428+
update_style=self.update_style,
4429+
update_threshold=self.update_threshold,
4430+
)
4431+
4432+
self.output.write_wall_time(tic, task='Env Selection')
4433+
4434+
if not std_in_bound:
4435+
print(f"[AB-INITIO CALLED] For structure with id={index}")
4436+
else:
4437+
print(f"[BFFS USED] For structure with id={index}")
4438+
candidate_indices.remove(index) # remove index computed via ML-FF
4439+
4440+
self.energies[index] = deepcopy(atoms.get_potential_energy()) / Rydberg # eV -> Ry
4441+
self.forces[index] = deepcopy(atoms.get_forces()) / Rydberg # eV/Ang -> Ry/Ang
4442+
if self.has_stress:
4443+
self.stresses[index, :, :] = -1 * deepcopy(atoms.get_stress(voigt=False)) * (Bohr**3 / Rydberg) # -eV/(Ang^3) -> Ry/(Bohr^3)
4444+
self.stress_computed[index] = True
4445+
4446+
self.force_computed[index] = True
4447+
4448+
sys.stdout.flush()
4449+
4450+
def _compute_properties(self, atoms):
4451+
"""Compute energies, forces, stresses, and their uncertainties."""
4452+
tic = time.time()
4453+
4454+
atoms.calc = self.flare_calc
4455+
atoms.calc.calculate(atoms)
4456+
4457+
self.output.write_wall_time(tic, task='Compute Properties')
4458+
4459+
def _write_model(self):
4460+
"""Write the current model in a JSON file."""
4461+
self.flare_calc.write_model(self.flare_name)
4462+
4463+
def _update_gp(self, atoms, dft_frcs, dft_energy=None, dft_stress=None):
4464+
"""Update the current GP model.
4465+
4466+
Args:
4467+
----
4468+
atoms: :class:`flare.atoms.FLARE_Atoms` instance whose local
4469+
environments will be added to the training set.
4470+
dft_frcs (np.ndarray): ab-initio forces in eV/Angstrom.
4471+
dft_energy (float): total energy of the structure, in eV.
4472+
dft_stress (np.ndarray): ab-initio stress, ASE sign convention,
4473+
eV/Angstrom^3, Voigt order (xx, yy, zz, yz, xz, xy), or None.
4474+
"""
4475+
from ase.calculators.singlepoint import SinglePointCalculator
4476+
_, compute_mae, get_env_indices, is_std_in_bound = _import_flare_learners()
4477+
4478+
tic = time.time()
4479+
is_empty_model = len(self.gp_model.training_data) == 0
4480+
4481+
# Skip adding environments if the stds are within the user-defined
4482+
# boundaries, even if the ab-initio calculation was performed.
4483+
if is_empty_model:
4484+
std_in_bound = False
4485+
train_atoms = self.init_atoms
4486+
else:
4487+
self._compute_properties(atoms)
4488+
4489+
if self.build_mode == 'bayesian':
4490+
env_selection = is_std_in_bound
4491+
elif self.build_mode == 'direct':
4492+
env_selection = get_env_indices
4493+
4494+
tic = time.time()
4495+
4496+
std_in_bound, train_atoms = env_selection(
4497+
self.std_tolerance,
4498+
self.gp_model.force_noise,
4499+
atoms,
4500+
max_atoms_added=self.max_atoms_added,
4501+
update_style=self.update_style,
4502+
update_threshold=self.update_threshold,
4503+
)
4504+
4505+
self.output.write_wall_time(tic, task='Env Selection')
4506+
4507+
# compute mae and write to output
4508+
e_mae, e_mav, f_mae, f_mav, s_mae, s_mav = compute_mae(
4509+
atoms,
4510+
self.output.basename,
4511+
atoms.potential_energy,
4512+
atoms.forces,
4513+
atoms.stress,
4514+
dft_energy,
4515+
dft_frcs,
4516+
dft_stress,
4517+
False,
4518+
)
4519+
4520+
if not std_in_bound:
4521+
if not is_empty_model:
4522+
stds = self.flare_calc.results.get('stds', np.zeros_like(dft_frcs))
4523+
self.output.add_atom_info(train_atoms, stds)
4524+
4525+
# Convert ASE stress (xx, yy, zz, yz, xz, xy) to FLARE stress
4526+
# (xx, xy, xz, yy, yz, zz).
4527+
flare_stress = None
4528+
if dft_stress is not None:
4529+
flare_stress = -np.array([
4530+
dft_stress[0],
4531+
dft_stress[5],
4532+
dft_stress[4],
4533+
dft_stress[1],
4534+
dft_stress[3],
4535+
dft_stress[2],
4536+
])
4537+
4538+
results = {
4539+
'forces': dft_frcs,
4540+
'energy': dft_energy,
4541+
'free_energy': dft_energy,
4542+
'stress': dft_stress,
4543+
}
4544+
4545+
atoms.calc = SinglePointCalculator(atoms, **results)
4546+
4547+
# update gp model
4548+
self.gp_model.update_db(
4549+
atoms,
4550+
dft_frcs,
4551+
custom_range=train_atoms,
4552+
energy=dft_energy,
4553+
stress=flare_stress,
4554+
)
4555+
4556+
self.gp_model.set_L_alpha()
4557+
self.output.write_wall_time(tic, task='Update GP')
4558+
4559+
def _train_gp(self):
4560+
"""Optimize the hyperparameters of the current GP model."""
4561+
tic = time.time()
4562+
4563+
self.gp_model.train(logger_name=self.output_name + 'hyps.dat')
4564+
4565+
self.output.write_wall_time(tic, task='Train Hyps')
4566+
4567+
hyps, labels = self.gp_model.hyps_and_labels
4568+
if labels is None:
4569+
labels = self.gp_model.hyp_labels
4570+
4571+
self.output.write_hyps(
4572+
labels,
4573+
hyps,
4574+
tic, # actually here there should be the actual start time of the entire simulation
4575+
self.gp_model.likelihood,
4576+
self.gp_model.likelihood_gradient,
4577+
hyps_mask=self.gp_model.hyps_mask,
4578+
)
4579+
4580+
def _clean_runs(self, dft_counts, label="CALCULATIONS"):
4581+
"""Clean the failed runs and print summary.
4582+
4583+
Args:
4584+
----
4585+
dft_counts (int): number of performed ab-initio calculations.
4586+
label (str): identifier printed in the summary header.
4587+
"""
4588+
n_calcs = np.sum(self.force_computed.astype(int))
4589+
print('=============== SUMMARY {} =============== \n'.format(label))
4590+
print('Total structures included: ', n_calcs)
4591+
print('Structures not included : ', self.N-n_calcs)
4592+
if self.gp_model is not None:
4593+
print('Steps using OTF-ML model : ', self.N-dft_counts)
4594+
print()
4595+
print('===================== END OF SUMMARY ===================== \n')
4596+
if n_calcs != self.N:
4597+
self.remove_noncomputed()
4598+
42984599

42994600

43004601

0 commit comments

Comments
 (0)