bfflearn 0.1.0.dev1__py3-none-any.whl

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.
Files changed (55) hide show
  1. bff/__init__.py +30 -0
  2. bff/bayes/__init__.py +11 -0
  3. bff/bayes/gaussian_process.py +285 -0
  4. bff/bayes/kernels.py +71 -0
  5. bff/bayes/learning.py +484 -0
  6. bff/bayes/likelihoods.py +142 -0
  7. bff/bayes/posterior.py +62 -0
  8. bff/bayes/priors.py +179 -0
  9. bff/bayes/results.py +327 -0
  10. bff/bayes/utils.py +333 -0
  11. bff/cli.py +254 -0
  12. bff/data.py +93 -0
  13. bff/domain/__init__.py +12 -0
  14. bff/domain/bias.py +149 -0
  15. bff/domain/specs.py +347 -0
  16. bff/domain/trainset.py +201 -0
  17. bff/io/__init__.py +12 -0
  18. bff/io/colvars.py +186 -0
  19. bff/io/commands.py +20 -0
  20. bff/io/cp2k.py +330 -0
  21. bff/io/logs.py +213 -0
  22. bff/io/mdp.py +98 -0
  23. bff/io/plumed.py +184 -0
  24. bff/io/progress.py +74 -0
  25. bff/io/schedulers.py +118 -0
  26. bff/io/utils.py +208 -0
  27. bff/mcmc/__init__.py +3 -0
  28. bff/mcmc/convergence.py +168 -0
  29. bff/mcmc/proposal.py +254 -0
  30. bff/mcmc/sampler.py +481 -0
  31. bff/plotting.py +362 -0
  32. bff/qoi/__init__.py +9 -0
  33. bff/qoi/analysis.py +209 -0
  34. bff/qoi/data.py +169 -0
  35. bff/qoi/hbonds.py +255 -0
  36. bff/qoi/rdf.py +108 -0
  37. bff/qoi/routines.py +272 -0
  38. bff/tools.py +168 -0
  39. bff/topology.py +581 -0
  40. bff/workflows/__init__.py +0 -0
  41. bff/workflows/configs.py +1147 -0
  42. bff/workflows/learn.py +77 -0
  43. bff/workflows/md.py +326 -0
  44. bff/workflows/prepare.py +788 -0
  45. bff/workflows/qoi.py +260 -0
  46. bff/workflows/runsims.py +620 -0
  47. bff/workflows/simulate.py +28 -0
  48. bff/workflows/train.py +69 -0
  49. bff/workflows/validate.py +29 -0
  50. bfflearn-0.1.0.dev1.dist-info/METADATA +913 -0
  51. bfflearn-0.1.0.dev1.dist-info/RECORD +55 -0
  52. bfflearn-0.1.0.dev1.dist-info/WHEEL +5 -0
  53. bfflearn-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  54. bfflearn-0.1.0.dev1.dist-info/licenses/LICENSE +674 -0
  55. bfflearn-0.1.0.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,620 @@
1
+ import shlex
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from gmxtopology import Topology
11
+
12
+ from ..domain.specs import ChargeConstraint, RandomParamsGenerator, Specs
13
+ from ..io.logs import Logger
14
+ from ..io.schedulers import Slurm
15
+ from ..io.utils import compress_results, load_yaml, save_yaml
16
+ from ..topology import TopologyModifier
17
+ from .configs import SimulateConfig, SimulationCampaignConfig, SimulationSystemConfig
18
+ from .md import modify_topology
19
+
20
+ PathLike = str | Path
21
+
22
+
23
+ SCHEDULER_CLASSES = {
24
+ "slurm": Slurm,
25
+ }
26
+
27
+
28
+ def _relative_path(path: Path | None, base_dir: Path) -> str | None:
29
+ if path is None:
30
+ return None
31
+ return str(path.relative_to(base_dir))
32
+
33
+
34
+ def _system_record(
35
+ system: SimulationSystemConfig,
36
+ trainset_dir: Path,
37
+ ) -> dict[str, Any]:
38
+ """Serialize one staged system into the campaign metadata file."""
39
+ return {
40
+ "system_id": system.system_id,
41
+ "topology": _relative_path(system.fn_topol, trainset_dir),
42
+ "coordinates": _relative_path(system.fn_coordinates, trainset_dir),
43
+ "mdp": {
44
+ "em": _relative_path(system.fn_mdp_em, trainset_dir),
45
+ "prod": _relative_path(system.fn_mdp_prod, trainset_dir),
46
+ },
47
+ "index": _relative_path(system.fn_ndx, trainset_dir),
48
+ "bias": _relative_path(system.bias.input_file, trainset_dir),
49
+ "n_steps": int(system.n_steps),
50
+ }
51
+
52
+
53
+ def build_specs(config: SimulateConfig) -> Path:
54
+ """Create the force-field specification file for one sampled campaign."""
55
+ config.trainset_dir.resolve().mkdir(parents=True, exist_ok=True)
56
+ top_modifier = TopologyModifier(
57
+ config.systems[0].fn_topol,
58
+ config.mol_resname,
59
+ config.implicit_atoms,
60
+ )
61
+ bounds_resolved = top_modifier.resolve_parameter_names(config.bounds)
62
+
63
+ group: list[str] = []
64
+ for name in bounds_resolved:
65
+ param_name, *atoms = name.split()
66
+ if param_name == "charge":
67
+ group.extend(atoms)
68
+ group_charge = top_modifier.group_charge(group)
69
+ constraint_charge = (
70
+ config.total_charge - top_modifier.total_charge + group_charge
71
+ )
72
+
73
+ specs = Specs(
74
+ {
75
+ "mol_resname": config.mol_resname,
76
+ "implicit_atoms": [atom.name for atom in top_modifier.implicit_atoms],
77
+ "bounds": bounds_resolved,
78
+ "total_charge": config.total_charge,
79
+ "charge_target": constraint_charge,
80
+ }
81
+ )
82
+ fn_specs = config.trainset_dir / "specs.yaml"
83
+ specs.write(fn_specs)
84
+ return fn_specs
85
+
86
+
87
+ def stage_systems(
88
+ systems: list[SimulationSystemConfig],
89
+ trainset_dir: Path,
90
+ ) -> list[SimulationSystemConfig]:
91
+ """Copy static simulation-system inputs into the campaign directory."""
92
+ staged_systems: list[SimulationSystemConfig] = []
93
+ for index, system in enumerate(systems):
94
+ fn_topol = trainset_dir / f"window-{index:03d}.top"
95
+ Topology(system.fn_topol).write(fn_topol, overwrite=True)
96
+
97
+ fn_coordinates = trainset_dir / f"window-{index:03d}.gro"
98
+ shutil.copy2(system.fn_coordinates, fn_coordinates)
99
+
100
+ fn_mdp_em = None
101
+ if system.fn_mdp_em is not None:
102
+ fn_mdp_em = trainset_dir / f"window-{index:03d}.em.mdp"
103
+ shutil.copy2(system.fn_mdp_em, fn_mdp_em)
104
+
105
+ fn_mdp_prod = trainset_dir / f"window-{index:03d}.mdp"
106
+ shutil.copy2(system.fn_mdp_prod, fn_mdp_prod)
107
+
108
+ fn_ndx = trainset_dir / f"window-{index:03d}.ndx"
109
+ shutil.copy2(system.fn_ndx, fn_ndx)
110
+
111
+ bias = system.bias
112
+ if bias.input_file is not None and bias.input_filename is not None:
113
+ fn_bias = trainset_dir / f"window-{index:03d}.{bias.input_filename}"
114
+ shutil.copy2(bias.input_file, fn_bias)
115
+ bias = type(bias).load(fn_bias)
116
+
117
+ staged_systems.append(
118
+ SimulationSystemConfig(
119
+ system_id=system.system_id,
120
+ fn_topol=fn_topol,
121
+ fn_coordinates=fn_coordinates,
122
+ fn_mdp_em=fn_mdp_em,
123
+ fn_mdp_prod=fn_mdp_prod,
124
+ fn_ndx=fn_ndx,
125
+ bias=bias,
126
+ n_steps=system.n_steps,
127
+ )
128
+ )
129
+ return staged_systems
130
+
131
+
132
+ def stage_campaign(
133
+ config: SimulationCampaignConfig,
134
+ *,
135
+ fn_specs: Path | None = None,
136
+ ) -> tuple[Path | None, list[SimulationSystemConfig]]:
137
+ """Prepare the trainset directory and write static campaign inputs."""
138
+ trainset_dir = config.trainset_dir.resolve()
139
+ trainset_dir.mkdir(parents=True, exist_ok=True)
140
+
141
+ systems = stage_systems(config.systems, trainset_dir)
142
+ if fn_specs is not None:
143
+ resolved_specs = fn_specs.resolve()
144
+ else:
145
+ resolved_specs = None
146
+
147
+ save_yaml(
148
+ {
149
+ "systems": [_system_record(system, trainset_dir) for system in systems],
150
+ "samples": {},
151
+ },
152
+ trainset_dir / "samples.yaml",
153
+ )
154
+ return resolved_specs, systems
155
+
156
+
157
+ def write_sample_job_config(
158
+ *,
159
+ sample_id: str,
160
+ sample: np.ndarray,
161
+ trainset_dir: Path,
162
+ fn_specs: Path,
163
+ gmx_cmd: str,
164
+ job_scheduler: str,
165
+ store: tuple[str, ...],
166
+ systems: list[SimulationSystemConfig],
167
+ ) -> Path:
168
+ """Write one MD job configuration for a sampled force-field vector."""
169
+ config_md = {
170
+ "sample_id": sample_id,
171
+ "params": np.asarray(sample, dtype=float).tolist(),
172
+ "trainset_dir": str(trainset_dir),
173
+ "fn_specs": str(fn_specs.resolve()),
174
+ "gmx_cmd": gmx_cmd,
175
+ "job_scheduler": job_scheduler,
176
+ "store": list(store),
177
+ "systems": [system.to_dict() for system in systems],
178
+ }
179
+ fn_config_md = trainset_dir / f"config-{sample_id}.yaml"
180
+ save_yaml(config_md, fn_config_md)
181
+ return fn_config_md
182
+
183
+
184
+ def stage_sample_topologies(
185
+ *,
186
+ sample_id: str,
187
+ sample: np.ndarray,
188
+ trainset_dir: Path,
189
+ fn_specs: Path,
190
+ systems: list[SimulationSystemConfig],
191
+ ) -> None:
192
+ """Write modified per-sample topology files into the campaign directory."""
193
+ for i, system in enumerate(systems):
194
+ fn_topol = trainset_dir / f"md-{sample_id}-{i:03d}.top"
195
+ modify_topology(system.fn_topol, fn_specs, sample, True, fn_topol)
196
+
197
+
198
+ def build_submission_script(
199
+ *,
200
+ sample_id: str,
201
+ fn_config_md: Path,
202
+ config: SimulationCampaignConfig,
203
+ ) -> Slurm | None:
204
+ """Build the Slurm submission script for one staged sample."""
205
+ if config.job_scheduler != "slurm":
206
+ return None
207
+
208
+ trainset_dir = config.trainset_dir.resolve()
209
+ repo_root = Path(__file__).resolve().parents[2]
210
+ cmd_run = [sys.executable, "-m", "bff.cli", "md", str(fn_config_md)]
211
+
212
+ submit_cls = SCHEDULER_CLASSES[config.job_scheduler]
213
+ assert config.slurm is not None
214
+ fn_stdout = trainset_dir / f"run-{sample_id}.out"
215
+ submit_specs = dict(config.slurm.sbatch or {}) | {"output": fn_stdout}
216
+ submit_script = submit_cls(**submit_specs)
217
+
218
+ for cmd in config.slurm.setup:
219
+ submit_script.add_command(cmd)
220
+ pythonpath_prefix = (
221
+ f"PYTHONPATH={shlex.quote(str(repo_root))}"
222
+ '${PYTHONPATH:+":$PYTHONPATH"}'
223
+ )
224
+ submit_script.add_command(f"{pythonpath_prefix} {shlex.join(cmd_run)}")
225
+ for cmd in config.slurm.teardown:
226
+ submit_script.add_command(cmd)
227
+
228
+ return submit_script
229
+
230
+
231
+ def write_submission_script(
232
+ *,
233
+ sample_id: str,
234
+ fn_config_md: Path,
235
+ config: SimulationCampaignConfig,
236
+ ) -> Path | None:
237
+ """Write the Slurm submission script for one staged sample."""
238
+ submit_script = build_submission_script(
239
+ sample_id=sample_id,
240
+ fn_config_md=fn_config_md,
241
+ config=config,
242
+ )
243
+ if submit_script is None:
244
+ return None
245
+
246
+ fn_submit = config.trainset_dir.resolve() / f"run-{sample_id}.sh"
247
+ submit_script.save(fn_submit)
248
+ return fn_submit
249
+
250
+
251
+ def dispatch_simulation_job(
252
+ *,
253
+ sample_id: str,
254
+ config: SimulationCampaignConfig,
255
+ ) -> int | None:
256
+ """Run or submit one MD job for a force-field parameter vector."""
257
+ trainset_dir = config.trainset_dir.resolve()
258
+ fn_config_md = trainset_dir / f"config-{sample_id}.yaml"
259
+ cmd_run = [sys.executable, "-m", "bff.cli", "md", str(fn_config_md)]
260
+ if config.job_scheduler == "local":
261
+ subprocess.run(cmd_run, cwd=str(trainset_dir), check=True)
262
+ return None
263
+
264
+ submit_script = build_submission_script(
265
+ sample_id=sample_id,
266
+ fn_config_md=fn_config_md,
267
+ config=config,
268
+ )
269
+ assert submit_script is not None
270
+ fn_submit = trainset_dir / f"run-{sample_id}.sh"
271
+ return submit_script.submit(fn_submit)
272
+
273
+
274
+ def get_active_jobs(ids: list[int], scheduler: str, chunk_size: int = 1000) -> int:
275
+ """Count active jobs for the supported scheduler."""
276
+ if scheduler != "slurm":
277
+ raise NotImplementedError
278
+
279
+ def chunks(values: list[int], n: int):
280
+ for i in range(0, len(values), n):
281
+ yield values[i:i+n]
282
+
283
+ n_active = 0
284
+ for chunk in chunks(ids, chunk_size):
285
+ ids_str = ",".join(map(str, chunk))
286
+ res = subprocess.run(
287
+ ["squeue", "-j", ids_str, "--noheader", "--format", "%i,%t"],
288
+ capture_output=True,
289
+ text=True,
290
+ check=False,
291
+ )
292
+ n_active += res.stdout.strip().count("\n")
293
+ if res.stdout.strip() and not res.stdout.endswith("\n"):
294
+ n_active += 1
295
+
296
+ return n_active
297
+
298
+
299
+ def wait_for_scheduler_slot(
300
+ *,
301
+ job_ids: list[int],
302
+ scheduler: str,
303
+ max_parallel_jobs: float,
304
+ ) -> None:
305
+ """Wait until the scheduler has capacity for one more submitted job."""
306
+ while get_active_jobs(job_ids, scheduler) >= max_parallel_jobs:
307
+ time.sleep(5)
308
+
309
+
310
+ def control_jobs(job_ids: list[int], scheduler: str) -> None:
311
+ """Block until all submitted jobs finish."""
312
+ while True:
313
+ if get_active_jobs(job_ids, scheduler) == 0:
314
+ break
315
+ time.sleep(5)
316
+
317
+
318
+ def _result_record(sample_id: str, trainset_dir: Path) -> dict[str, Any]:
319
+ fn_result = trainset_dir / f"result-{sample_id}.yaml"
320
+ if fn_result.exists():
321
+ return load_yaml(fn_result)
322
+ return {}
323
+
324
+
325
+ def collect_campaign_metadata(
326
+ *,
327
+ samples: dict[str, dict[str, Any]],
328
+ systems: list[SimulationSystemConfig],
329
+ trainset_dir: Path,
330
+ compress: bool = False,
331
+ remove: bool = False,
332
+ ) -> None:
333
+ """Merge per-sample result files into the final campaign metadata file."""
334
+ sample_records: dict[str, Any] = {}
335
+ for sample_id, sample_data in samples.items():
336
+ result = _result_record(sample_id, trainset_dir)
337
+ sample_records[sample_id] = {
338
+ "params": sample_data["params"],
339
+ "job_id": sample_data.get("job_id"),
340
+ "status": result.get("status", sample_data.get("status", "failed")),
341
+ "outputs": result.get("outputs", sample_data.get("outputs", [])),
342
+ }
343
+
344
+ save_yaml(
345
+ {
346
+ "systems": [_system_record(system, trainset_dir) for system in systems],
347
+ "samples": sample_records,
348
+ },
349
+ trainset_dir / "samples.yaml",
350
+ )
351
+
352
+ for fn_result in trainset_dir.glob("result-*.yaml"):
353
+ fn_result.unlink(missing_ok=True)
354
+
355
+ if compress:
356
+ compress_results(trainset_dir)
357
+ if remove:
358
+ shutil.rmtree(trainset_dir)
359
+ return
360
+
361
+ if remove:
362
+ patterns = ["run-*.sh", "run-*.out", "config-*.yaml"]
363
+ for pattern in patterns:
364
+ for file in trainset_dir.glob(pattern):
365
+ file.unlink(missing_ok=True)
366
+
367
+
368
+ def print_simulate_summary(
369
+ config: SimulateConfig,
370
+ fn_specs: PathLike,
371
+ logger: Logger,
372
+ ) -> None:
373
+ """Print a concise summary of the sampled simulation campaign."""
374
+ specs = Specs(fn_specs)
375
+ logger.info("", level=0)
376
+ logger.info("=== Running simulation campaign ===\n", level=0)
377
+ logger.info(f"molecule name: {specs.mol_resname}", level=1)
378
+ logger.info(f"trainset dir: {config.trainset_dir.resolve()}", level=1)
379
+ logger.info(
380
+ f"systems: {len(config.systems)} | samples: {config.n_samples} "
381
+ f"| scheduler: {config.job_scheduler}",
382
+ level=1,
383
+ )
384
+ logger.info(
385
+ f"dispatch: {'yes' if config.dispatch else 'no (stage only)'}",
386
+ level=1,
387
+ )
388
+ logger.info(
389
+ f"stored outputs: {', '.join(config.store) if config.store else 'none'}",
390
+ level=1,
391
+ )
392
+ logger.info("parameters:", level=1)
393
+ for name, bounds in specs.bounds.by_name.items():
394
+ label = f"{name}: {bounds}"
395
+ if name == specs.implicit_param:
396
+ label += " (implicit)"
397
+ logger.info(label, level=2)
398
+ logger.info(f"total charge: {specs.total_charge}\n", level=1)
399
+
400
+
401
+ def print_validate_summary(
402
+ config: SimulationCampaignConfig,
403
+ fn_specs: PathLike,
404
+ n_samples: int,
405
+ logger: Logger,
406
+ ) -> None:
407
+ """Print a concise summary of the validation campaign."""
408
+ specs = Specs(fn_specs)
409
+ logger.info("", level=0)
410
+ logger.info("=== Running validation campaign ===\n", level=0)
411
+ logger.info(f"molecule name: {specs.mol_resname}", level=1)
412
+ logger.info(f"trainset dir: {config.trainset_dir.resolve()}", level=1)
413
+ logger.info(
414
+ f"systems: {len(config.systems)} | samples: {n_samples} "
415
+ f"| scheduler: {config.job_scheduler}",
416
+ level=1,
417
+ )
418
+ logger.info(
419
+ f"dispatch: {'yes' if config.dispatch else 'no (stage only)'}",
420
+ level=1,
421
+ )
422
+ logger.info(
423
+ f"stored outputs: {', '.join(config.store) if config.store else 'none'}\n",
424
+ level=1,
425
+ )
426
+ parameter_source = getattr(config, "parameters", None)
427
+ if parameter_source is not None:
428
+ logger.info(
429
+ f"parameter source: {Path(parameter_source).resolve()}\n",
430
+ level=1,
431
+ )
432
+
433
+
434
+ def run_campaign(
435
+ *,
436
+ config: SimulationCampaignConfig,
437
+ fn_specs: Path,
438
+ systems: list[SimulationSystemConfig],
439
+ parameter_samples: np.ndarray,
440
+ logger: Logger,
441
+ ) -> None:
442
+ """Run a local or Slurm-backed simulation campaign."""
443
+ n_total = len(parameter_samples)
444
+ samples: dict[str, dict[str, Any]] = {}
445
+ job_ids: list[int] = []
446
+ pad = len(str(max(n_total, 1)))
447
+ trainset_dir = config.trainset_dir.resolve()
448
+ job_scheduler = config.job_scheduler
449
+ max_parallel_jobs = None
450
+ action = "Running MD" if config.dispatch else "Staging jobs"
451
+ campaign_finished = False
452
+
453
+ if config.dispatch and job_scheduler not in {"local", *SCHEDULER_CLASSES}:
454
+ raise NotImplementedError(
455
+ f"Unsupported scheduler '{job_scheduler}'. "
456
+ f"Supported: {['local', *SCHEDULER_CLASSES]}"
457
+ )
458
+
459
+ try:
460
+ for idx, sample in enumerate(parameter_samples):
461
+ sample_id = f"{idx:0{pad}d}"
462
+ logger.info(
463
+ f"{action}: {idx + 1}/{n_total} "
464
+ f"({((idx + 1) / n_total * 100):.0f}%)",
465
+ level=1,
466
+ overwrite=True,
467
+ )
468
+
469
+ sample = np.asarray(sample, dtype=float).reshape(-1)
470
+ fn_config_md = write_sample_job_config(
471
+ sample_id=sample_id,
472
+ sample=sample,
473
+ trainset_dir=trainset_dir,
474
+ fn_specs=fn_specs,
475
+ gmx_cmd=config.gmx_cmd,
476
+ job_scheduler=config.job_scheduler,
477
+ store=config.store,
478
+ systems=systems,
479
+ )
480
+ samples[sample_id] = {
481
+ "params": sample.tolist(),
482
+ "job_id": None,
483
+ "status": "staged" if not config.dispatch else "failed",
484
+ "outputs": [],
485
+ }
486
+
487
+ if not config.dispatch:
488
+ stage_sample_topologies(
489
+ sample_id=sample_id,
490
+ sample=sample,
491
+ trainset_dir=trainset_dir,
492
+ fn_specs=fn_specs,
493
+ systems=systems,
494
+ )
495
+ write_submission_script(
496
+ sample_id=sample_id,
497
+ fn_config_md=fn_config_md,
498
+ config=config,
499
+ )
500
+ continue
501
+
502
+ if job_scheduler == "local":
503
+ dispatch_simulation_job(
504
+ sample_id=sample_id,
505
+ config=config,
506
+ )
507
+ continue
508
+
509
+ assert config.slurm is not None
510
+ max_parallel_jobs = config.slurm.max_parallel_jobs
511
+ max_parallel_jobs = np.inf if max_parallel_jobs == -1 else max_parallel_jobs
512
+ if max_parallel_jobs > 0:
513
+ wait_for_scheduler_slot(
514
+ job_ids=job_ids,
515
+ scheduler=job_scheduler,
516
+ max_parallel_jobs=max_parallel_jobs,
517
+ )
518
+ job_id = dispatch_simulation_job(
519
+ sample_id=sample_id,
520
+ config=config,
521
+ )
522
+ if job_id is not None:
523
+ job_ids.append(job_id)
524
+ samples[sample_id]["job_id"] = job_id
525
+
526
+ if (
527
+ config.dispatch
528
+ and job_scheduler != "local"
529
+ and max_parallel_jobs is not None
530
+ and max_parallel_jobs > 0
531
+ ):
532
+ control_jobs(job_ids, job_scheduler)
533
+
534
+ campaign_finished = True
535
+ logger.info(f"{action}: {n_total}/{n_total} (100%) | Done.", level=1)
536
+ finally:
537
+ collect_campaign_metadata(
538
+ samples=samples,
539
+ systems=systems,
540
+ trainset_dir=trainset_dir,
541
+ compress=(
542
+ config.compress
543
+ if config.dispatch and campaign_finished
544
+ else False
545
+ ),
546
+ remove=(
547
+ config.cleanup
548
+ if config.dispatch and campaign_finished
549
+ else False
550
+ ),
551
+ )
552
+
553
+
554
+ def build_parameter_samples(config: SimulateConfig) -> tuple[Path, np.ndarray]:
555
+ """Build ``specs.yaml`` and sample explicit parameters for one campaign."""
556
+ fn_specs = build_specs(config)
557
+ constraint = ChargeConstraint(fn_specs)
558
+ sampler = RandomParamsGenerator(constraint.explicit_bounds, constraint)
559
+ parameter_samples = np.asarray(
560
+ [sampler(1).squeeze(0) for _ in range(config.n_samples)],
561
+ dtype=float,
562
+ )
563
+ return fn_specs, parameter_samples
564
+
565
+
566
+ def _load_yaml_parameter_samples(
567
+ data: dict[str, Any],
568
+ *,
569
+ specs: Specs,
570
+ ) -> np.ndarray:
571
+ explicit_names = list(specs.parameter_names(explicit_only=True))
572
+
573
+ if all(name in data for name in explicit_names):
574
+ lengths = {len(data[name]) for name in explicit_names}
575
+ if len(lengths) != 1:
576
+ raise ValueError(
577
+ "Column-oriented YAML sample lists must all have the same length."
578
+ )
579
+ return np.column_stack(
580
+ [np.asarray(data[name], dtype=float) for name in explicit_names]
581
+ )
582
+
583
+ raise ValueError(
584
+ "Unsupported YAML parameter-sample format. Expected a column-oriented "
585
+ "mapping keyed by explicit parameter names."
586
+ )
587
+
588
+
589
+ def load_parameter_samples(
590
+ fn_samples: PathLike,
591
+ fn_specs: PathLike,
592
+ ) -> np.ndarray:
593
+ """Load validation parameter samples from a column-oriented ``.yaml`` file."""
594
+ fn_samples = Path(fn_samples).resolve()
595
+ specs = Specs(fn_specs)
596
+
597
+ if fn_samples.suffix != ".yaml":
598
+ raise ValueError(
599
+ f"Unsupported sample file {fn_samples}. Expected a .yaml file."
600
+ )
601
+ raw = load_yaml(fn_samples)
602
+ if not isinstance(raw, dict):
603
+ raise ValueError(
604
+ f"YAML parameter sample file {fn_samples} must contain a mapping."
605
+ )
606
+ samples = _load_yaml_parameter_samples(raw, specs=specs)
607
+
608
+ if samples.ndim != 2:
609
+ raise ValueError(
610
+ f"Parameter samples must form a 2D array, got shape {samples.shape}."
611
+ )
612
+ expected = len(specs.parameter_names(explicit_only=True))
613
+ if samples.shape[1] != expected:
614
+ raise ValueError(
615
+ f"Expected {expected} explicit parameter values per sample, got "
616
+ f"{samples.shape[1]}."
617
+ )
618
+ if samples.shape[0] == 0:
619
+ raise ValueError("No validation parameter samples were found.")
620
+ return samples
@@ -0,0 +1,28 @@
1
+ """Workflow entry point for sampled training-set simulations."""
2
+
3
+ from ..io.logs import Logger
4
+ from .configs import SimulateConfig
5
+ from .runsims import (
6
+ build_parameter_samples,
7
+ print_simulate_summary,
8
+ run_campaign,
9
+ stage_campaign,
10
+ )
11
+
12
+
13
+ def main(fn_config: str) -> None:
14
+ """Run a sampled simulation campaign for training-set generation."""
15
+ config = SimulateConfig.load(fn_config)
16
+ fn_specs, parameter_samples = build_parameter_samples(config)
17
+ resolved_specs, systems = stage_campaign(config, fn_specs=fn_specs)
18
+ assert resolved_specs is not None
19
+
20
+ logger = Logger("simulate")
21
+ print_simulate_summary(config, resolved_specs, logger)
22
+ run_campaign(
23
+ config=config,
24
+ fn_specs=resolved_specs,
25
+ systems=systems,
26
+ parameter_samples=parameter_samples,
27
+ logger=logger,
28
+ )