firefly-activemd 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: firefly-activemd
3
+ Version: 0.3.0
4
+ Summary: Active Learning Molecular Dynamics with MACE potentials and DFT reference calculations
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: ase>=3.28
8
+ Requires-Dist: mace-torch>=0.3
9
+ Requires-Dist: numpy>=2
10
+ Requires-Dist: sisl>=0.16
11
+
12
+ # Firefly
13
+
14
+ Active Learning Molecular Dynamics with MACE potentials and DFT reference calculations (SIESTA or Quantum ESPRESSO).
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install .
20
+ ```
21
+
22
+ Or in editable mode (for development):
23
+
24
+ ```bash
25
+ pip install -e .
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ firefly parameters.json
32
+ ```
33
+
34
+ ## Parameters (parameters.json)
35
+
36
+ | Key | Required | Description |
37
+ |-----|----------|-------------|
38
+ | `file` | yes | Path to the atomic structure file (.xyz, etc.) |
39
+ | `ensemble` | yes | MD ensemble: `iso`, `aniso`, `nptz`, `nptxy`, `bussi`, `ortho` |
40
+ | `code` | yes | DFT code: `siesta` or `qe` |
41
+ | `cmd` | yes | Command to run the DFT code |
42
+ | `thresh` | yes | Uncertainty threshold for active learning |
43
+ | `n_models` | yes | Number of MACE models in the committee |
44
+ | `atoms` | siesta only | Dict mapping element symbols to atomic numbers |
45
+ | `gpus` | no | Number of GPUs (default: 1) |
46
+ | `T_begin` | no | Initial temperature in K (default: 300) |
47
+ | `T_final` | no | Final temperature in K (default: 300) |
48
+ | `pressure` | no | Pressure in bar (default: 0) |
49
+ | `dt` | no | Time step in fs (default: 0.5) |
50
+ | `time` | no | Total simulation time in fs (default: 100000) |
51
+ | `patience` | no | Steps before forcing DFT call (default: 1000) |
52
+ | `active` | no | Uncertainty metric: `energy` or `force` (default: `energy`) |
53
+ | `epoch` | no | Training epochs per retraining step (default: 10) |
54
+ | `premodel` | no | Model name prefix (default: `model`) |
@@ -0,0 +1,43 @@
1
+ # Firefly
2
+
3
+ Active Learning Molecular Dynamics with MACE potentials and DFT reference calculations (SIESTA or Quantum ESPRESSO).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install .
9
+ ```
10
+
11
+ Or in editable mode (for development):
12
+
13
+ ```bash
14
+ pip install -e .
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ firefly parameters.json
21
+ ```
22
+
23
+ ## Parameters (parameters.json)
24
+
25
+ | Key | Required | Description |
26
+ |-----|----------|-------------|
27
+ | `file` | yes | Path to the atomic structure file (.xyz, etc.) |
28
+ | `ensemble` | yes | MD ensemble: `iso`, `aniso`, `nptz`, `nptxy`, `bussi`, `ortho` |
29
+ | `code` | yes | DFT code: `siesta` or `qe` |
30
+ | `cmd` | yes | Command to run the DFT code |
31
+ | `thresh` | yes | Uncertainty threshold for active learning |
32
+ | `n_models` | yes | Number of MACE models in the committee |
33
+ | `atoms` | siesta only | Dict mapping element symbols to atomic numbers |
34
+ | `gpus` | no | Number of GPUs (default: 1) |
35
+ | `T_begin` | no | Initial temperature in K (default: 300) |
36
+ | `T_final` | no | Final temperature in K (default: 300) |
37
+ | `pressure` | no | Pressure in bar (default: 0) |
38
+ | `dt` | no | Time step in fs (default: 0.5) |
39
+ | `time` | no | Total simulation time in fs (default: 100000) |
40
+ | `patience` | no | Steps before forcing DFT call (default: 1000) |
41
+ | `active` | no | Uncertainty metric: `energy` or `force` (default: `energy`) |
42
+ | `epoch` | no | Training epochs per retraining step (default: 10) |
43
+ | `premodel` | no | Model name prefix (default: `model`) |
@@ -0,0 +1,2 @@
1
+ """ActiveMD: Active Learning Molecular Dynamics with MACE potentials."""
2
+ __version__ = "0.3.0"
@@ -0,0 +1,127 @@
1
+ import sys
2
+ import json
3
+ import logging
4
+ from ase import units
5
+ from ase.io import read
6
+ import firefly.simulations.ensemble as sim
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ActiveMD:
12
+ def __init__(self, config):
13
+ self.config = config
14
+
15
+ erros = []
16
+
17
+ if "file" not in self.config or not self.config["file"]:
18
+ erros.append("The path to the atomic structure file ('file') is mandatory.")
19
+
20
+ if "ensemble" not in self.config or not self.config["ensemble"]:
21
+ erros.append("The simulation ensemble type ('ensemble') is mandatory (e.g. 'iso', 'aniso', 'bussi', etc.).")
22
+
23
+ if "code" not in self.config or not self.config["code"]:
24
+ erros.append("The reference DFT code ('code') is mandatory (use 'siesta' or 'qe').")
25
+
26
+ if "cmd" not in self.config or not self.config["cmd"]:
27
+ erros.append("The execution command for the DFT software ('cmd') is mandatory.")
28
+
29
+ if "thresh" not in self.config or self.config.get("thresh") is None:
30
+ erros.append("The uncertainty threshold ('thresh') is mandatory for Active Learning.")
31
+
32
+ if "n_models" not in self.config or self.config.get("n_models") is None:
33
+ erros.append("The number of models in the ensemble ('n_models') is mandatory.")
34
+
35
+ self.code = self.config.get("code")
36
+ if self.code == 'siesta':
37
+ if "atoms" not in self.config or not self.config["atoms"]:
38
+ erros.append("The 'atoms' dictionary mapping symbols to atomic numbers is mandatory when using Siesta.")
39
+
40
+ if erros:
41
+ logger.error("=" * 60)
42
+ logger.error(" CONFIGURATION ERROR DETECTED IN PARAMETERS.JSON")
43
+ logger.error("=" * 60)
44
+ logger.error("The following mandatory options are missing or invalid:")
45
+ for erro in erros:
46
+ logger.error(f" -> {erro}")
47
+ logger.error("=" * 60)
48
+ sys.exit(1)
49
+
50
+ import os
51
+ caminho_arquivo = self.config["file"]
52
+ if not os.path.exists(caminho_arquivo):
53
+ logger.error("=" * 60)
54
+ logger.error(" ERROR: ATOMIC STRUCTURE FILE NOT FOUND")
55
+ logger.error("=" * 60)
56
+ logger.error(f" The file '{caminho_arquivo}' specified in 'file' does not exist.")
57
+ logger.error("=" * 60)
58
+ sys.exit(1)
59
+
60
+ self.ensemble = self.config["ensemble"]
61
+ self.atoms = read(caminho_arquivo, index='0')
62
+ self.T_beg = self.config.get("T_begin", 300.0)
63
+ self.T_fin = self.config.get("T_final", 300.0)
64
+ self.name = self.config.get("premodel", "model")
65
+ self.n = self.config["n_models"]
66
+ self.dict = self.config.get("atoms", {})
67
+ self.patience = self.config.get("patience", 1000)
68
+ self.thresh = self.config["thresh"]
69
+ self.cmd = self.config["cmd"]
70
+ self.time = self.config.get("time", 100000.0) * units.fs
71
+ self.interval = self.patience
72
+ self.dt = self.config.get("dt", 0.5) * units.fs
73
+ self.active = self.config.get("active", "energy")
74
+ self.epoch = self.config.get("epoch", 10)
75
+
76
+ pressao_valor = self.config.get("pressure")
77
+ if pressao_valor is not None:
78
+ self.press = pressao_valor * units.bar
79
+ else:
80
+ self.press = 0.0 * units.bar
81
+
82
+ def iniciar(self):
83
+ logger.info("Starting molecular dynamics and processing parameters...")
84
+
85
+ mapa_ensembles = {
86
+ 'aniso': sim.aniso,
87
+ 'iso': sim.iso,
88
+ 'nptz': sim.NPTz,
89
+ 'nptxy': sim.NPTxy,
90
+ 'bussi': sim.bussi,
91
+ 'ortho': sim.ortho,
92
+ }
93
+
94
+ funcao_simulacao = mapa_ensembles.get(self.ensemble.lower())
95
+ if funcao_simulacao:
96
+ funcao_simulacao(data=self)
97
+ else:
98
+ logger.error(f"The ensemble '{self.ensemble}' is not supported by the program.")
99
+ sys.exit(1)
100
+
101
+
102
+ def main():
103
+ logging.basicConfig(
104
+ level=logging.INFO,
105
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
106
+ datefmt="%Y-%m-%d %H:%M:%S",
107
+ )
108
+
109
+ if len(sys.argv) < 2:
110
+ logger.error("Correct usage: firefly parameters.json")
111
+ sys.exit(1)
112
+
113
+ try:
114
+ logger.info(f"Loading configuration file: {sys.argv[1]}")
115
+ with open(sys.argv[1], 'r') as arquivo:
116
+ parametros = json.load(arquivo)
117
+
118
+ app = ActiveMD(parametros)
119
+ app.iniciar()
120
+
121
+ except FileNotFoundError:
122
+ logger.error(f"Error: File '{sys.argv[1]}' was not found.")
123
+ sys.exit(1)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
File without changes
@@ -0,0 +1,265 @@
1
+ import time
2
+ import logging
3
+ import numpy as np
4
+ from ase import units
5
+ from ase.cell import Cell
6
+ from ase.io import read, write, Trajectory
7
+ from ase.optimize import LBFGS
8
+ from ase.md.langevinbaoab import LangevinBAOAB
9
+ from ase.md.npt import NPT
10
+ from ase.md.langevin import Langevin
11
+ from ase.md.bussi import Bussi
12
+ from ase.md.nptberendsen import NPTBerendsen
13
+ from ase.md.nptberendsen import Inhomogeneous_NPTBerendsen
14
+ from ase.md.velocitydistribution import MaxwellBoltzmannDistribution, Stationary, ZeroRotation
15
+ from ase.calculators.mixing import AverageCalculator
16
+ from mace.calculators import MACECalculator
17
+ from ase.md.nose_hoover_chain import MaskedMTKNPT
18
+ from ase.md.nose_hoover_chain import MTKNPT
19
+ from ase.md.nose_hoover_chain import IsotropicMTKNPT
20
+
21
+ import firefly.utils.ThreshComp as thresh
22
+ import firefly.utils.MaceUtils as mu
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ amu_A3_to_g_cm3 = 1.660539
27
+ GPa_to_atm = 9869.23
28
+ GPa_to_eVA3 = 1. / 160.21766208
29
+ atm_to_eVA3 = (1. / GPa_to_atm) * GPa_to_eVA3
30
+
31
+
32
+ class TemperatureRamp:
33
+ def __init__(self, dyn, T_start, T_end, Nsteps):
34
+ self.dyn = dyn
35
+ self.Nsteps = Nsteps
36
+ self.T_start = T_start
37
+ self.T_end = T_end
38
+
39
+ def __call__(self):
40
+ a = (self.Nsteps - self.dyn.nsteps) / self.Nsteps
41
+ T = self.T_start * a + self.T_end * (1.0 - a)
42
+ if self.dyn.__class__.__name__ == 'Bussi':
43
+ self.dyn.temperature_K = T
44
+ self.dyn.temp = T * units.kB
45
+ self.dyn.target_kinetic_energy = 0.5 * self.dyn.temp * self.dyn.ndof
46
+ elif hasattr(self.dyn, 'set_temperature'):
47
+ self.dyn.set_temperature(temperature_K=T)
48
+ elif hasattr(self.dyn._thermostat, 'set_temperature'):
49
+ self.dyn._thermostat.set_temperature(temperature_K=T)
50
+ else:
51
+ self.dyn._thermostat._kT = T * units.kB
52
+
53
+
54
+ class PrintEnergy:
55
+ def __init__(self, dyn, interval=1):
56
+ self.dyn = dyn
57
+ self.interval = interval
58
+ self.time_start = time.time()
59
+ self.time_last = self.time_start
60
+
61
+ def __call__(self):
62
+ a = self.dyn.atoms
63
+ step = self.dyn.nsteps
64
+ epot = a.get_potential_energy() / len(a)
65
+ ekin = a.get_kinetic_energy() / len(a)
66
+ temp = a.get_temperature()
67
+ stress = a.get_stress() / GPa_to_eVA3
68
+ press = ((stress[0] + stress[1] + stress[2]) / 3) * GPa_to_atm
69
+ dens = (a.get_masses().sum() / a.get_volume()) * amu_A3_to_g_cm3
70
+ cell = a.cell.cellpar()
71
+ time_now = time.time()
72
+ elapsed_time = time_now - self.time_last
73
+ self.time_last = time_now
74
+ spcpu = self.interval / elapsed_time
75
+
76
+ with open("therm.dat", "a") as f:
77
+ f.write('%d: T= %.2f K P= %.1f atm K= %.5e U= %.5e rho= %.3e spcpu= %.1f' % (step, temp, press, ekin, epot, dens, spcpu) + ' [%.3f %.3f %.3f %.2f %.2f %.2f]\n' % tuple(cell))
78
+
79
+
80
+ def _get_calculators(data):
81
+ num_gpus = data.config.get("gpus", 1)
82
+ models = [f"model{i+1}.model" for i in range(data.n)]
83
+ calculators = []
84
+ for i, model_path in enumerate(models):
85
+ gpu_id = i % num_gpus
86
+ device = f"cuda:{gpu_id}"
87
+ logger.info(f"Loading {model_path} on device {device}")
88
+ calculators.append(MACECalculator(model_paths=model_path, device=device))
89
+ return calculators
90
+
91
+
92
+ def aniso(data):
93
+ atoms = data.atoms
94
+ data.calculadoras = _get_calculators(data)
95
+ data.calc = AverageCalculator(data.calculadoras)
96
+ atoms.calc = data.calc
97
+
98
+ press = data.press
99
+ Tdamp = 100 * data.dt
100
+ Pdamp = 1000 * data.dt
101
+ n_steps = int(data.time / data.dt)
102
+
103
+ MaxwellBoltzmannDistribution(atoms, temperature_K=data.T_beg)
104
+ dyn = MTKNPT(data.atoms, timestep=data.dt, temperature_K=data.T_beg, pressure_au=press, tdamp=Tdamp, pdamp=Pdamp)
105
+ data.dyn = dyn
106
+
107
+ if data.active == 'force':
108
+ dyn.attach(lambda: thresh.thresh_forces(data=data), interval=1)
109
+ if data.active == 'energy':
110
+ dyn.attach(lambda: thresh.thresh_energies(data=data), interval=1)
111
+
112
+ dyn.attach(TemperatureRamp(dyn, data.T_beg, data.T_fin, n_steps), interval=100)
113
+ dyn.attach(PrintEnergy(dyn, 1), interval=1)
114
+ traj = Trajectory('run.traj', 'w', atoms)
115
+ dyn.attach(traj.write, interval=100)
116
+
117
+ logger.info("Starting the simulation")
118
+ dyn.run(n_steps)
119
+
120
+
121
+ def iso(data):
122
+ atoms = data.atoms
123
+ data.calculadoras = _get_calculators(data)
124
+ data.calc = AverageCalculator(data.calculadoras)
125
+ atoms.calc = data.calc
126
+
127
+ press = data.press
128
+ n_steps = int(data.time / data.dt)
129
+
130
+ MaxwellBoltzmannDistribution(data.atoms, temperature_K=data.T_beg)
131
+ dyn = IsotropicMTKNPT(data.atoms, timestep=data.dt, temperature_K=data.T_beg, pressure_au=press, tdamp=100 * units.fs, pdamp=1000 * units.fs)
132
+ data.dyn = dyn
133
+
134
+ if data.active == 'force':
135
+ dyn.attach(lambda: thresh.thresh_forces(data=data), interval=1)
136
+ if data.active == 'energy':
137
+ dyn.attach(lambda: thresh.thresh_energies(data=data), interval=1)
138
+
139
+ dyn.attach(TemperatureRamp(dyn, data.T_beg, data.T_fin, n_steps), interval=100)
140
+ dyn.attach(PrintEnergy(dyn, 1), interval=1)
141
+ traj = Trajectory('run.traj', 'w', atoms)
142
+ dyn.attach(traj.write, interval=100)
143
+
144
+ logger.info("Starting the simulation")
145
+ dyn.run(n_steps)
146
+
147
+
148
+ def ortho(data):
149
+ atoms = data.atoms
150
+ data.calculadoras = _get_calculators(data)
151
+ data.calc = AverageCalculator(data.calculadoras)
152
+ atoms.calc = data.calc
153
+
154
+ press = data.press
155
+ Tdamp = 100 * data.dt
156
+ Pdamp = 1000 * data.dt
157
+ n_steps = int(data.time / data.dt)
158
+
159
+ MaxwellBoltzmannDistribution(data.atoms, temperature_K=data.T_beg)
160
+ dyn = MaskedMTKNPT(data.atoms, timestep=data.dt, temperature_K=data.T_beg, pressure_au=press, tdamp=Tdamp, pdamp=Pdamp, mask=(1, 1, 1))
161
+ data.dyn = dyn
162
+
163
+ if data.active == 'force':
164
+ dyn.attach(lambda: thresh.thresh_forces(data=data), interval=1)
165
+ if data.active == 'energy':
166
+ dyn.attach(lambda: thresh.thresh_energies(data=data), interval=1)
167
+
168
+ dyn.attach(TemperatureRamp(dyn, data.T_beg, data.T_fin, n_steps), interval=100)
169
+ dyn.attach(PrintEnergy(dyn, 1), interval=1)
170
+ traj = Trajectory('run.traj', 'w', atoms)
171
+ dyn.attach(traj.write, interval=100)
172
+
173
+ logger.info("Starting the simulation")
174
+ dyn.run(n_steps)
175
+
176
+
177
+ def NPTz(data):
178
+ atoms = data.atoms
179
+ data.calculadoras = _get_calculators(data)
180
+ data.calc = AverageCalculator(data.calculadoras)
181
+ atoms.calc = data.calc
182
+
183
+ press = data.press
184
+ Tdamp = 100 * data.dt
185
+ Pdamp = 1000 * data.dt
186
+ n_steps = int(data.time / data.dt)
187
+
188
+ MaxwellBoltzmannDistribution(data.atoms, temperature_K=data.T_beg)
189
+ Stationary(data.atoms)
190
+ ZeroRotation(data.atoms)
191
+ dyn = MaskedMTKNPT(data.atoms, timestep=data.dt, temperature_K=data.T_beg, pressure_au=press, tdamp=Tdamp, pdamp=Pdamp, mask=[False, False, True])
192
+ data.dyn = dyn
193
+
194
+ if data.active == 'force':
195
+ dyn.attach(lambda: thresh.thresh_forces(data=data), interval=1)
196
+ if data.active == 'energy':
197
+ dyn.attach(lambda: thresh.thresh_energies(data=data), interval=1)
198
+
199
+ dyn.attach(TemperatureRamp(dyn, data.T_beg, data.T_fin, n_steps), interval=100)
200
+ dyn.attach(PrintEnergy(dyn, 1), interval=1)
201
+ traj = Trajectory('run.traj', 'w', atoms)
202
+ dyn.attach(traj.write, interval=100)
203
+
204
+ logger.info("Starting the simulation")
205
+ dyn.run(n_steps)
206
+
207
+
208
+ def NPTxy(data):
209
+ atoms = data.atoms
210
+ data.calculadoras = _get_calculators(data)
211
+ data.calc = AverageCalculator(data.calculadoras)
212
+ atoms.calc = data.calc
213
+
214
+ press = data.press
215
+ Tdamp = 100 * data.dt
216
+ Pdamp = 1000 * data.dt
217
+ n_steps = int(data.time / data.dt)
218
+
219
+ MaxwellBoltzmannDistribution(data.atoms, temperature_K=data.T_beg)
220
+ Stationary(data.atoms)
221
+ ZeroRotation(data.atoms)
222
+ dyn = MaskedMTKNPT(data.atoms, timestep=data.dt, temperature_K=data.T_beg, pressure_au=press, tdamp=Tdamp, pdamp=Pdamp, mask=[True, True, False])
223
+ data.dyn = dyn
224
+
225
+ if data.active == 'force':
226
+ dyn.attach(lambda: thresh.thresh_forces(data=data), interval=1)
227
+ if data.active == 'energy':
228
+ dyn.attach(lambda: thresh.thresh_energies(data=data), interval=1)
229
+
230
+ dyn.attach(TemperatureRamp(dyn, data.T_beg, data.T_fin, n_steps), interval=100)
231
+ dyn.attach(PrintEnergy(dyn, 1), interval=1)
232
+ traj = Trajectory('run.traj', 'w', atoms)
233
+ dyn.attach(traj.write, interval=100)
234
+
235
+ logger.info("Starting the simulation")
236
+ dyn.run(n_steps)
237
+
238
+
239
+ def bussi(data):
240
+ atoms = data.atoms
241
+ data.calculadoras = _get_calculators(data)
242
+ data.calc = AverageCalculator(data.calculadoras)
243
+ atoms.calc = data.calc
244
+
245
+ Tdamp = 100 * data.dt
246
+ n_steps = int(data.time / data.dt)
247
+
248
+ MaxwellBoltzmannDistribution(data.atoms, temperature_K=data.T_beg)
249
+ Stationary(data.atoms)
250
+ ZeroRotation(data.atoms)
251
+ dyn = Bussi(data.atoms, timestep=data.dt, temperature_K=data.T_beg, taut=Tdamp)
252
+ data.dyn = dyn
253
+
254
+ if data.active == 'force':
255
+ dyn.attach(lambda: thresh.thresh_forces(data=data), interval=1)
256
+ if data.active == 'energy':
257
+ dyn.attach(lambda: thresh.thresh_energies(data=data), interval=1)
258
+
259
+ dyn.attach(TemperatureRamp(dyn, data.T_beg, data.T_fin, n_steps), interval=100)
260
+ dyn.attach(PrintEnergy(dyn, 1), interval=1)
261
+ traj = Trajectory('run.traj', 'w', atoms)
262
+ dyn.attach(traj.write, interval=100)
263
+
264
+ logger.info("Starting the simulation")
265
+ dyn.run(n_steps)
@@ -0,0 +1,111 @@
1
+ import os
2
+ import glob
3
+ import logging
4
+ import numpy as np
5
+ from ase import Atoms
6
+ from ase.io import write, read
7
+ from ase.data import atomic_numbers, chemical_symbols
8
+ from ase.calculators.singlepoint import SinglePointCalculator
9
+ from pathlib import Path
10
+ import sisl
11
+ from sisl.io.siesta import stdoutSileSiesta
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def create_siesta(data):
17
+ logger.info("Starting DFT reference calculation with SIESTA...")
18
+ celli = data.atoms.get_cell()
19
+ symbols = data.atoms.get_chemical_symbols()
20
+ posicoes = data.atoms.get_positions()
21
+
22
+ with open("geom.in", "w") as arq:
23
+ for sym, pos2 in zip(symbols, posicoes):
24
+ x, y, z = pos2
25
+ Z = data.dict[sym]
26
+ arq.write(f"{x:12.6f} {y:12.6f} {z:12.6f} {Z:3d}\n")
27
+
28
+ with open("lattice.fdf", "w") as arq:
29
+ arq.write("%block LatticeVectors\n")
30
+ c = celli.reshape(1, 9)[0]
31
+ arq.write(f"{c[0]:12.6f} {c[1]:12.6f} {c[2]:12.6f}\n")
32
+ arq.write(f"{c[3]:12.6f} {c[4]:12.6f} {c[5]:12.6f}\n")
33
+ arq.write(f"{c[6]:12.6f} {c[7]:12.6f} {c[8]:12.6f}\n")
34
+ arq.write("%endblock LatticeVectors\n")
35
+
36
+ write("last_add.xyz", data.atoms)
37
+
38
+ os.system(data.cmd + " < RUN.fdf > saida.out")
39
+
40
+ geom = sisl.get_sile("siesta.XV").read_geometry()
41
+ out = stdoutSileSiesta("saida.out")
42
+ E = out.read_energy()
43
+ Etot = E['total']
44
+ force = np.loadtxt("siesta.FA", skiprows=1)
45
+ forces = force[:, 1:4]
46
+ symbols_geom = [chemical_symbols[z] for z in geom.atoms.Z]
47
+ stress = out.read_stress()
48
+
49
+ atoms2 = Atoms(symbols=symbols_geom, positions=geom.xyz, cell=geom.cell, pbc=True)
50
+ atoms2.info['REF_energy'] = Etot
51
+ if stress is not None:
52
+ atoms2.info['REF_stress'] = stress
53
+ atoms2.arrays['REF_forces'] = forces
54
+
55
+ write("train.xyz", atoms2, append=True)
56
+
57
+ padroes_temporarios = [
58
+ "siesta*", "BASIS_*", "NON_TRIMMED_KP_LIST", "FORCE_STRESS", "OUTVARS.yml",
59
+ "PARALLEL_DIST", "TIMES", "*ion*", "MESSAGES", "CLOCK", "fdf*", "INPUT_TMP.*",
60
+ "geom.in", "lattice.fdf", "last_add.xyz", "saida.out",
61
+ ]
62
+ for padrao in padroes_temporarios:
63
+ for f in glob.glob(padrao):
64
+ try:
65
+ os.remove(f)
66
+ except OSError:
67
+ pass
68
+
69
+
70
+ def create_qe(data):
71
+ logger.info("Starting DFT reference calculation with Quantum ESPRESSO (QE)...")
72
+ celli = data.atoms.get_cell()
73
+ symbols = data.atoms.get_chemical_symbols()
74
+ posicoes = data.atoms.get_positions()
75
+
76
+ with open("geom.in", "w") as arq:
77
+ arq.write("\nATOMIC_POSITIONS angstrom\n")
78
+ for sym, pos2 in zip(symbols, posicoes):
79
+ x, y, z = pos2
80
+ arq.write(f"{sym:4s} {x:12.6f} {y:12.6f} {z:12.6f}\n")
81
+
82
+ with open("lattice.in", "w") as arq:
83
+ arq.write("\nCELL_PARAMETERS angstrom\n")
84
+ c = celli.reshape(1, 9)[0]
85
+ arq.write(f"{c[0]:12.6f} {c[1]:12.6f} {c[2]:12.6f}\n")
86
+ arq.write(f"{c[3]:12.6f} {c[4]:12.6f} {c[5]:12.6f}\n")
87
+ arq.write(f"{c[6]:12.6f} {c[7]:12.6f} {c[8]:12.6f}\n")
88
+
89
+ with open("run.in", "w") as outfile:
90
+ for fname in ["espresso.in", "geom.in", "lattice.in"]:
91
+ with open(fname) as infile:
92
+ outfile.write(infile.read())
93
+
94
+ os.system(data.cmd + " < run.in > saida.out")
95
+
96
+ atoms3 = read('saida.out', format='espresso-out')
97
+ atoms3.info['REF_energy'] = atoms3.get_potential_energy()
98
+ atoms3.arrays['REF_forces'] = atoms3.get_forces()
99
+ if atoms3.get_stress() is not None:
100
+ atoms3.info['REF_stress'] = atoms3.get_stress()
101
+
102
+ write('train.xyz', atoms3, append=True)
103
+
104
+ padroes_temporarios = ["geom.in", "lattice.in", "run.in", "saida.out", "active.save", "active.xml"]
105
+ for f in padroes_temporarios:
106
+ if os.path.exists(f):
107
+ if os.path.isdir(f):
108
+ import shutil
109
+ shutil.rmtree(f)
110
+ else:
111
+ os.remove(f)
@@ -0,0 +1,138 @@
1
+ import glob
2
+ import re
3
+ import os
4
+ import subprocess
5
+ import time
6
+ import logging
7
+ from mace.calculators import MACECalculator
8
+ from ase.calculators.mixing import AverageCalculator
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _get_calculators(data):
14
+ num_gpus = data.config.get("gpus", 1)
15
+ models = [f"model{i+1}.model" for i in range(data.n)]
16
+ calculators = []
17
+ for i, model_path in enumerate(models):
18
+ gpu_id = i % num_gpus
19
+ device = f"cuda:{gpu_id}"
20
+ logger.info(f"Loading {model_path} on device {device}")
21
+ calculators.append(MACECalculator(model_paths=model_path, device=device))
22
+ return calculators
23
+
24
+
25
+ def renew_model(data):
26
+ data.calculadoras = None
27
+ data.calculadoras = _get_calculators(data)
28
+ new_calc = AverageCalculator(data.calculadoras)
29
+ data.atoms.calc = new_calc
30
+ logger.info("The model was restarted and distributed across GPUs")
31
+
32
+
33
+ def train_parallel(data):
34
+ n = data.n
35
+ ep_train = data.epoch
36
+ ep_swa = data.epoch // 2
37
+
38
+ gpus_config = data.config.get("gpus", 1)
39
+ if isinstance(gpus_config, int):
40
+ gpus_disponiveis = list(range(gpus_config))
41
+ elif isinstance(gpus_config, list):
42
+ gpus_disponiveis = gpus_config
43
+ else:
44
+ gpus_disponiveis = [0]
45
+
46
+ num_gpus = len(gpus_disponiveis)
47
+ logger.info(f"[Parallel Training] Starting training of {n} models on {num_gpus} GPU(s): {gpus_disponiveis}")
48
+
49
+ jobs_para_executar = []
50
+ for i in range(n):
51
+ prefixo = data.name + str(i + 1)
52
+ arquivos = glob.glob(f"checkpoints/{prefixo}_run-*_epoch-*.pt")
53
+
54
+ max_epoch = max((int(re.search(r"epoch-(\d+)", f).group(1)) for f in arquivos), default=0)
55
+ novo_max = max_epoch + ep_train
56
+ novo_max_swa = max_epoch + ep_swa
57
+
58
+ job_file = f"job_{i+1}.sh"
59
+ if os.path.exists(job_file):
60
+ with open(job_file, "r") as f:
61
+ conteudo = f.read()
62
+ conteudo = re.sub(r"(--max_num_epochs=)\d+", f"\\1{novo_max}", conteudo)
63
+ conteudo = re.sub(r"(--start_swa=)\d+", f"\\1{novo_max_swa}", conteudo)
64
+ with open(job_file, "w") as f:
65
+ f.write(conteudo)
66
+
67
+ jobs_para_executar.append({
68
+ "id_modelo": i + 1,
69
+ "cmd": ["sh", job_file],
70
+ "arquivos_para_limpar": arquivos,
71
+ "prefixo": prefixo,
72
+ })
73
+
74
+ processos_ativos = {}
75
+ jobs_restantes = list(jobs_para_executar)
76
+ jobs_concluidos = []
77
+ falhas = []
78
+
79
+ while jobs_restantes or processos_ativos:
80
+ for gpu_id in gpus_disponiveis:
81
+ if gpu_id not in processos_ativos and jobs_restantes:
82
+ job = jobs_restantes.pop(0)
83
+ modelo_id = job["id_modelo"]
84
+ logger.info(f" -> Allocating Model {modelo_id} on GPU {gpu_id}...")
85
+ env_subprocesso = os.environ.copy()
86
+ env_subprocesso["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
87
+ processo = subprocess.Popen(
88
+ job["cmd"],
89
+ stdout=subprocess.PIPE,
90
+ stderr=subprocess.PIPE,
91
+ env=env_subprocesso,
92
+ text=True,
93
+ )
94
+ processos_ativos[gpu_id] = (processo, job)
95
+
96
+ time.sleep(1.0)
97
+
98
+ gpus_liberadas = []
99
+ for gpu_id, (proc, job) in processos_ativos.items():
100
+ status = proc.poll()
101
+ if status is not None:
102
+ modelo_id = job["id_modelo"]
103
+ gpus_liberadas.append(gpu_id)
104
+ if status == 0:
105
+ logger.info(f" [OK] Model {modelo_id} completed on GPU {gpu_id}.")
106
+ modelo_saida = f"{data.name}{modelo_id}_stagetwo.model"
107
+ modelo_destino = f"model{modelo_id}.model"
108
+ if os.path.exists(modelo_saida):
109
+ os.rename(modelo_saida, modelo_destino)
110
+ else:
111
+ logger.warning(f" [Warning] File {modelo_saida} not found post-training.")
112
+ for f in job["arquivos_para_limpar"]:
113
+ if os.path.exists(f):
114
+ os.remove(f)
115
+ jobs_concluidos.append(job)
116
+ else:
117
+ _, stderr_output = proc.communicate()
118
+ logger.error(f" [ERROR] Model {modelo_id} failed on GPU {gpu_id} with code {status}.")
119
+ logger.error(f"Details:\n{stderr_output}")
120
+ falhas.append((modelo_id, stderr_output))
121
+
122
+ for gpu_id in gpus_liberadas:
123
+ del processos_ativos[gpu_id]
124
+
125
+ for i in range(n):
126
+ prefixo = data.name + str(i + 1)
127
+ for temp_file in glob.glob(f"{prefixo}*"):
128
+ if os.path.exists(temp_file):
129
+ os.remove(temp_file)
130
+
131
+ if falhas:
132
+ erros_str = ", ".join([f"Model {m_id}" for m_id, _ in falhas])
133
+ raise RuntimeError(f"Parallel training failed for models: {erros_str}.")
134
+
135
+ logger.info("[Parallel Training] All models have been successfully updated!")
136
+
137
+
138
+ train_paralell = train_parallel
@@ -0,0 +1,97 @@
1
+ import numpy as np
2
+ import logging
3
+ import firefly.utils.DFTCodes as cd
4
+ import firefly.utils.MaceUtils as mu
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def thresh_forces(data):
10
+ if not hasattr(data, 'calculadoras') or data.calculadoras is None:
11
+ data.calculadoras = mu._get_calculators(data)
12
+
13
+ atoms = data.atoms
14
+ calc_main = atoms.calc
15
+ committee_forces = []
16
+
17
+ for calc in data.calculadoras:
18
+ atoms.calc = calc
19
+ committee_forces.append(atoms.get_forces())
20
+
21
+ committee_forces = np.array(committee_forces)
22
+ std_forces = np.std(committee_forces, axis=0)
23
+ val_incerteza_estrutura = np.sqrt(np.mean(np.square(std_forces)))
24
+
25
+ if val_incerteza_estrutura > data.thresh:
26
+ logger.warning(f"Force uncertainty exceeded threshold! Value: {val_incerteza_estrutura:.6f} > Threshold: {data.thresh:.6f}")
27
+
28
+ if data.interval < data.patience and val_incerteza_estrutura < 3 * data.thresh:
29
+ logger.info(f"Patience active. Current interval: {data.interval}/{data.patience}")
30
+ data.atoms.calc = calc_main
31
+ data.interval += 1
32
+ else:
33
+ logger.warning("Patience limit exceeded or uncertainty too high (>= 3 * thresh). Starting DFT calculation and retraining.")
34
+ data.interval = 0
35
+ data.otf = val_incerteza_estrutura
36
+ passo_atual = data.dyn.get_number_of_steps()
37
+
38
+ with open("thresh.dat", "a") as f:
39
+ f.write(f"{passo_atual}\t{val_incerteza_estrutura:.6f}\n")
40
+
41
+ if data.code == 'siesta':
42
+ cd.create_siesta(data=data)
43
+ mu.train_parallel(data=data)
44
+ mu.renew_model(data=data)
45
+
46
+ if data.code == 'qe':
47
+ cd.create_qe(data=data)
48
+ mu.train_parallel(data=data)
49
+ mu.renew_model(data=data)
50
+ else:
51
+ data.atoms.calc = calc_main
52
+ data.interval += 1
53
+
54
+
55
+ def thresh_energies(data):
56
+ if not hasattr(data, 'calculadoras') or data.calculadoras is None:
57
+ data.calculadoras = mu._get_calculators(data)
58
+
59
+ atoms = data.atoms
60
+ calc_main = atoms.calc
61
+ committee_energies = []
62
+
63
+ for calc in data.calculadoras:
64
+ atoms.calc = calc
65
+ committee_energies.append(atoms.get_potential_energy())
66
+
67
+ committee_energies = np.array(committee_energies)
68
+ std_energy = np.std(committee_energies)
69
+
70
+ if std_energy > data.thresh:
71
+ logger.warning(f"Energy uncertainty exceeded threshold! Value: {std_energy:.6f} > Threshold: {data.thresh:.6f}")
72
+
73
+ if data.interval < data.patience and std_energy < 3 * data.thresh:
74
+ logger.info(f"Patience active. Current interval: {data.interval}/{data.patience}")
75
+ data.atoms.calc = calc_main
76
+ data.interval += 1
77
+ else:
78
+ logger.warning("Patience limit exceeded or uncertainty too high (>= 3 * thresh). Starting DFT calculation and retraining.")
79
+ data.interval = 0
80
+ data.otf = std_energy
81
+ passo_atual = data.dyn.get_number_of_steps()
82
+
83
+ with open("thresh.dat", "a") as f:
84
+ f.write(f"{passo_atual}\t{std_energy:.6f}\n")
85
+
86
+ if data.code == 'siesta':
87
+ cd.create_siesta(data=data)
88
+ mu.train_parallel(data=data)
89
+ mu.renew_model(data=data)
90
+
91
+ if data.code == 'qe':
92
+ cd.create_qe(data=data)
93
+ mu.train_parallel(data=data)
94
+ mu.renew_model(data=data)
95
+ else:
96
+ data.atoms.calc = calc_main
97
+ data.interval += 1
File without changes
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: firefly-activemd
3
+ Version: 0.3.0
4
+ Summary: Active Learning Molecular Dynamics with MACE potentials and DFT reference calculations
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: ase>=3.28
8
+ Requires-Dist: mace-torch>=0.3
9
+ Requires-Dist: numpy>=2
10
+ Requires-Dist: sisl>=0.16
11
+
12
+ # Firefly
13
+
14
+ Active Learning Molecular Dynamics with MACE potentials and DFT reference calculations (SIESTA or Quantum ESPRESSO).
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install .
20
+ ```
21
+
22
+ Or in editable mode (for development):
23
+
24
+ ```bash
25
+ pip install -e .
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ firefly parameters.json
32
+ ```
33
+
34
+ ## Parameters (parameters.json)
35
+
36
+ | Key | Required | Description |
37
+ |-----|----------|-------------|
38
+ | `file` | yes | Path to the atomic structure file (.xyz, etc.) |
39
+ | `ensemble` | yes | MD ensemble: `iso`, `aniso`, `nptz`, `nptxy`, `bussi`, `ortho` |
40
+ | `code` | yes | DFT code: `siesta` or `qe` |
41
+ | `cmd` | yes | Command to run the DFT code |
42
+ | `thresh` | yes | Uncertainty threshold for active learning |
43
+ | `n_models` | yes | Number of MACE models in the committee |
44
+ | `atoms` | siesta only | Dict mapping element symbols to atomic numbers |
45
+ | `gpus` | no | Number of GPUs (default: 1) |
46
+ | `T_begin` | no | Initial temperature in K (default: 300) |
47
+ | `T_final` | no | Final temperature in K (default: 300) |
48
+ | `pressure` | no | Pressure in bar (default: 0) |
49
+ | `dt` | no | Time step in fs (default: 0.5) |
50
+ | `time` | no | Total simulation time in fs (default: 100000) |
51
+ | `patience` | no | Steps before forcing DFT call (default: 1000) |
52
+ | `active` | no | Uncertainty metric: `energy` or `force` (default: `energy`) |
53
+ | `epoch` | no | Training epochs per retraining step (default: 10) |
54
+ | `premodel` | no | Model name prefix (default: `model`) |
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ firefly/__init__.py
4
+ firefly/cli.py
5
+ firefly/simulations/__init__.py
6
+ firefly/simulations/ensemble.py
7
+ firefly/utils/DFTCodes.py
8
+ firefly/utils/MaceUtils.py
9
+ firefly/utils/ThreshComp.py
10
+ firefly/utils/__init__.py
11
+ firefly_activemd.egg-info/PKG-INFO
12
+ firefly_activemd.egg-info/SOURCES.txt
13
+ firefly_activemd.egg-info/dependency_links.txt
14
+ firefly_activemd.egg-info/entry_points.txt
15
+ firefly_activemd.egg-info/requires.txt
16
+ firefly_activemd.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ firefly = firefly.cli:main
@@ -0,0 +1,4 @@
1
+ ase>=3.28
2
+ mace-torch>=0.3
3
+ numpy>=2
4
+ sisl>=0.16
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "firefly-activemd"
7
+ version = "0.3.0"
8
+ description = "Active Learning Molecular Dynamics with MACE potentials and DFT reference calculations"
9
+ readme = {file = "README.md", content-type = "text/markdown"}
10
+ requires-python = ">=3.8"
11
+ dependencies = [
12
+ "ase>=3.28",
13
+ "mace-torch>=0.3",
14
+ "numpy>=2",
15
+ "sisl>=0.16",
16
+ ]
17
+
18
+ [project.scripts]
19
+ firefly = "firefly.cli:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
23
+ include = ["firefly*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+