simjecture 0.1.0__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.
- conjecture_solver/__init__.py +10 -0
- conjecture_solver/__main__.py +4 -0
- conjecture_solver/action_handlers.py +459 -0
- conjecture_solver/adapters/__init__.py +7 -0
- conjecture_solver/adapters/base.py +96 -0
- conjecture_solver/adapters/fake.py +155 -0
- conjecture_solver/adapters/pic.py +196 -0
- conjecture_solver/adapters/warpx.py +1375 -0
- conjecture_solver/autonomous_research.py +1071 -0
- conjecture_solver/benchmarks/__init__.py +6 -0
- conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
- conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
- conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
- conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
- conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
- conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
- conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
- conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
- conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
- conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
- conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
- conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
- conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
- conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
- conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
- conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
- conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
- conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
- conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
- conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
- conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
- conjecture_solver/campaign.py +406 -0
- conjecture_solver/cli.py +963 -0
- conjecture_solver/confirmation.py +287 -0
- conjecture_solver/control.py +234 -0
- conjecture_solver/deployment.py +874 -0
- conjecture_solver/discovery.py +68 -0
- conjecture_solver/domains/__init__.py +40 -0
- conjecture_solver/domains/base.py +157 -0
- conjecture_solver/domains/kinetic_sufficiency.py +521 -0
- conjecture_solver/ledger.py +238 -0
- conjecture_solver/lifecycle.py +131 -0
- conjecture_solver/literature.py +494 -0
- conjecture_solver/llm.py +228 -0
- conjecture_solver/models.py +279 -0
- conjecture_solver/mvp_agent.py +3212 -0
- conjecture_solver/mvp_claims.py +1279 -0
- conjecture_solver/mvp_control.py +322 -0
- conjecture_solver/mvp_guidance.py +172 -0
- conjecture_solver/mvp_launch.py +1296 -0
- conjecture_solver/mvp_monitor.py +1487 -0
- conjecture_solver/mvp_skills.py +538 -0
- conjecture_solver/orchestration.py +578 -0
- conjecture_solver/outbox.py +526 -0
- conjecture_solver/parameters.py +394 -0
- conjecture_solver/proposals.py +427 -0
- conjecture_solver/research_tools.py +278 -0
- conjecture_solver/schema_export.py +246 -0
- conjecture_solver/search.py +1047 -0
- conjecture_solver/semantics.py +146 -0
- conjecture_solver/tui/__init__.py +7 -0
- conjecture_solver/tui/app.py +202 -0
- conjecture_solver/tui/claim_views.py +213 -0
- conjecture_solver/tui/screens.py +1014 -0
- conjecture_solver/warpx_analysis.py +184 -0
- conjecture_solver/warpx_campaign.py +148 -0
- conjecture_solver/warpx_confirmation.py +617 -0
- simjecture-0.1.0.dist-info/METADATA +400 -0
- simjecture-0.1.0.dist-info/RECORD +75 -0
- simjecture-0.1.0.dist-info/WHEEL +4 -0
- simjecture-0.1.0.dist-info/entry_points.txt +4 -0
- simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
- simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
"""Restricted WarpX/PICMI compiler and execution boundary.
|
|
2
|
+
|
|
3
|
+
The model-facing surface is a small set of validated scalar parameters. This
|
|
4
|
+
module, not a language model, owns the executable PICMI template and the
|
|
5
|
+
result-validity rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import platform
|
|
15
|
+
import subprocess
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from statistics import median
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import Field, model_validator
|
|
24
|
+
|
|
25
|
+
from ..models import ExperimentSpec, StrictModel, utc_now
|
|
26
|
+
from ..search import SymmetricMixtureCandidate
|
|
27
|
+
from .base import (
|
|
28
|
+
CapabilityManifest,
|
|
29
|
+
CostEstimate,
|
|
30
|
+
JobReference,
|
|
31
|
+
JobState,
|
|
32
|
+
JobStatus,
|
|
33
|
+
NormalizedResult,
|
|
34
|
+
RawResult,
|
|
35
|
+
RunPackage,
|
|
36
|
+
ValidationReport,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
ELECTRON_CHARGE_C = 1.602176634e-19
|
|
40
|
+
ELECTRON_MASS_KG = 9.1093837139e-31
|
|
41
|
+
VACUUM_PERMITTIVITY_F_M = 8.8541878128e-12
|
|
42
|
+
SPEED_OF_LIGHT_M_S = 299792458.0
|
|
43
|
+
|
|
44
|
+
WARPX_ADAPTER_VERSION = "0.3.0"
|
|
45
|
+
WARPX_CONTRACT_VERSION = "warpx_picmi_pair_v1"
|
|
46
|
+
WARPX_ACTION = "warpx_kinetic_sufficiency_confirmation"
|
|
47
|
+
WARPX_MODEL = "warpx_electrostatic_1d3v_pic"
|
|
48
|
+
WARPX_REQUIRED_DIAGNOSTICS = (
|
|
49
|
+
"dominant_linear_mode",
|
|
50
|
+
"distribution_moments",
|
|
51
|
+
"field_energy",
|
|
52
|
+
"gauss_residual",
|
|
53
|
+
"openpmd_electric_field",
|
|
54
|
+
"particle_energy",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _canonical_json(value: Any) -> str:
|
|
59
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sha256_text(value: str) -> str:
|
|
63
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _warpx_versions_equivalent(left: str, right: str) -> bool:
|
|
67
|
+
"""Treat release spellings such as 26.07 and Python metadata 26.7 as equal."""
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return tuple(int(part) for part in left.split(".")) == tuple(
|
|
71
|
+
int(part) for part in right.split(".")
|
|
72
|
+
)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return left == right
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class WarpXRunnerKind(StrEnum):
|
|
78
|
+
CONTRACT_FIXTURE = "contract_fixture"
|
|
79
|
+
LOCAL_CPU = "local_cpu"
|
|
80
|
+
SITE_SCHEDULER = "site_scheduler"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WarpXExecutionProfile(StrictModel):
|
|
84
|
+
"""Identity and trust status of the process that executes a run package."""
|
|
85
|
+
|
|
86
|
+
profile_id: str = Field(min_length=1)
|
|
87
|
+
runner_kind: WarpXRunnerKind
|
|
88
|
+
warpx_version: str = Field(min_length=1)
|
|
89
|
+
openpmd_backend: Literal["h5"] = "h5"
|
|
90
|
+
qualification_hash: str | None = None
|
|
91
|
+
qualified_for_scientific_evidence: bool = False
|
|
92
|
+
|
|
93
|
+
@model_validator(mode="after")
|
|
94
|
+
def qualification_is_coherent(self) -> WarpXExecutionProfile:
|
|
95
|
+
if self.runner_kind is WarpXRunnerKind.CONTRACT_FIXTURE:
|
|
96
|
+
if self.qualified_for_scientific_evidence or self.qualification_hash is not None:
|
|
97
|
+
raise ValueError("a contract fixture can never be qualified as scientific evidence")
|
|
98
|
+
elif self.qualified_for_scientific_evidence and self.qualification_hash is None:
|
|
99
|
+
raise ValueError("an evidence-qualified profile requires a qualification hash")
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class WarpXPhysicalConfig(StrictModel):
|
|
104
|
+
reference_density_m3: float = Field(default=1.0e18, ge=1.0e12, le=1.0e25)
|
|
105
|
+
velocity_unit_m_s: float = Field(default=1.0e6, ge=1.0e3, le=2.0e7)
|
|
106
|
+
wavenumber_dimensionless: float = Field(default=0.5, ge=0.1, le=2.0)
|
|
107
|
+
perturbation_amplitude: float = Field(default=0.02, ge=1.0e-7, le=0.05)
|
|
108
|
+
inner_pair_weight: float = Field(default=0.5, gt=0, lt=1)
|
|
109
|
+
inner_drift: float = Field(default=0.95, ge=0, lt=1)
|
|
110
|
+
outer_drift: float = Field(default=0.95, ge=0, lt=1)
|
|
111
|
+
|
|
112
|
+
@model_validator(mode="after")
|
|
113
|
+
def candidate_is_safe(self) -> WarpXPhysicalConfig:
|
|
114
|
+
candidate = self.candidate
|
|
115
|
+
if (candidate.outer_drift + 6.0 * candidate.thermal_sigma) * self.velocity_unit_m_s > (
|
|
116
|
+
0.25 * SPEED_OF_LIGHT_M_S
|
|
117
|
+
):
|
|
118
|
+
raise ValueError("the declared nonrelativistic distribution extends above 0.25 c")
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def candidate(self) -> SymmetricMixtureCandidate:
|
|
123
|
+
return SymmetricMixtureCandidate(
|
|
124
|
+
inner_pair_weight=self.inner_pair_weight,
|
|
125
|
+
inner_drift=self.inner_drift,
|
|
126
|
+
outer_drift=self.outer_drift,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def plasma_frequency_rad_s(self) -> float:
|
|
131
|
+
return math.sqrt(
|
|
132
|
+
self.reference_density_m3
|
|
133
|
+
* ELECTRON_CHARGE_C**2
|
|
134
|
+
/ (ELECTRON_MASS_KG * VACUUM_PERMITTIVITY_F_M)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def wavenumber_m_inverse(self) -> float:
|
|
139
|
+
return self.wavenumber_dimensionless * self.plasma_frequency_rad_s / self.velocity_unit_m_s
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def domain_length_m(self) -> float:
|
|
143
|
+
return 2.0 * math.pi / self.wavenumber_m_inverse
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class WarpXNumericalConfig(StrictModel):
|
|
147
|
+
grid_cells: int = Field(default=64, ge=32, le=4096)
|
|
148
|
+
electron_macroparticles_per_cell: int = Field(default=512, ge=16, le=8192)
|
|
149
|
+
ion_macroparticles_per_cell: int = Field(default=16, ge=1, le=1024)
|
|
150
|
+
time_step_omega_pe: float = Field(default=0.05, ge=0.001, le=0.2)
|
|
151
|
+
final_time_omega_pe: float = Field(default=20.0, ge=1.0, le=500.0)
|
|
152
|
+
diagnostic_interval_steps: int = Field(default=2, ge=1, le=10000)
|
|
153
|
+
random_seed: int = Field(default=1, ge=1, le=2_147_483_647)
|
|
154
|
+
poisson_relative_tolerance: float = Field(default=1.0e-10, ge=1.0e-14, le=1.0e-4)
|
|
155
|
+
poisson_max_iterations: int = Field(default=200, ge=10, le=10000)
|
|
156
|
+
minimum_diagnostic_samples: int = Field(default=50, ge=3, le=10000)
|
|
157
|
+
early_window_start_omega_pe: float = Field(default=0.0, ge=0)
|
|
158
|
+
early_window_end_omega_pe: float = Field(default=4.0, gt=0)
|
|
159
|
+
late_window_start_omega_pe: float = Field(default=14.0, gt=0)
|
|
160
|
+
late_window_end_omega_pe: float = Field(default=18.0, gt=0)
|
|
161
|
+
minimum_window_samples: int = Field(default=20, ge=3, le=10000)
|
|
162
|
+
damped_ratio_threshold: float = Field(default=0.75, gt=0, lt=1)
|
|
163
|
+
unstable_ratio_threshold: float = Field(default=1.5, gt=1)
|
|
164
|
+
maximum_relative_energy_drift: float = Field(default=0.01, gt=0, le=1.0)
|
|
165
|
+
maximum_relative_gauss_residual: float = Field(default=0.01, gt=0, le=1.0)
|
|
166
|
+
maximum_relative_charge_imbalance: float = Field(default=0.01, gt=0, le=1.0)
|
|
167
|
+
moment_tolerance: float = Field(default=0.05, gt=0, le=0.5)
|
|
168
|
+
outcome_tolerance_omega_pe: float = Field(default=0.02, gt=0, le=1.0)
|
|
169
|
+
|
|
170
|
+
@model_validator(mode="after")
|
|
171
|
+
def discrete_layout_is_valid(self) -> WarpXNumericalConfig:
|
|
172
|
+
if self.grid_cells & (self.grid_cells - 1):
|
|
173
|
+
raise ValueError("grid_cells must be a power of two")
|
|
174
|
+
if self.electron_macroparticles_per_cell % 4:
|
|
175
|
+
raise ValueError("electron_macroparticles_per_cell must be divisible by four")
|
|
176
|
+
steps = self.final_time_omega_pe / self.time_step_omega_pe
|
|
177
|
+
if abs(steps - round(steps)) > 1.0e-10:
|
|
178
|
+
raise ValueError("final_time_omega_pe must contain an integer number of steps")
|
|
179
|
+
if self.max_steps % self.diagnostic_interval_steps:
|
|
180
|
+
raise ValueError("diagnostic_interval_steps must divide the total step count")
|
|
181
|
+
available_samples = self.max_steps // self.diagnostic_interval_steps + 1
|
|
182
|
+
if available_samples < self.minimum_diagnostic_samples:
|
|
183
|
+
raise ValueError("the diagnostic cadence cannot provide the required samples")
|
|
184
|
+
windows = (
|
|
185
|
+
self.early_window_start_omega_pe,
|
|
186
|
+
self.early_window_end_omega_pe,
|
|
187
|
+
self.late_window_start_omega_pe,
|
|
188
|
+
self.late_window_end_omega_pe,
|
|
189
|
+
)
|
|
190
|
+
if not 0 <= windows[0] < windows[1] < windows[2] < windows[3] <= self.final_time_omega_pe:
|
|
191
|
+
raise ValueError("the growth windows must be ordered and inside the run")
|
|
192
|
+
cadence = self.diagnostic_interval_steps * self.time_step_omega_pe
|
|
193
|
+
for start, end in ((windows[0], windows[1]), (windows[2], windows[3])):
|
|
194
|
+
samples = math.floor((end - start) / cadence + 1.0e-10) + 1
|
|
195
|
+
if samples < self.minimum_window_samples:
|
|
196
|
+
raise ValueError("a growth window cannot provide the required samples")
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def max_steps(self) -> int:
|
|
201
|
+
return round(self.final_time_omega_pe / self.time_step_omega_pe)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class WarpXCompiledCase(StrictModel):
|
|
205
|
+
case_name: Literal["unit_maxwellian_reference", "symmetric_mixture_candidate"]
|
|
206
|
+
script: str = Field(min_length=1)
|
|
207
|
+
script_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
208
|
+
|
|
209
|
+
@model_validator(mode="after")
|
|
210
|
+
def hash_matches_script(self) -> WarpXCompiledCase:
|
|
211
|
+
if _sha256_text(self.script) != self.script_hash:
|
|
212
|
+
raise ValueError("compiled PICMI script hash does not match its content")
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class WarpXCaseSummary(StrictModel):
|
|
217
|
+
case_name: Literal["unit_maxwellian_reference", "symmetric_mixture_candidate"]
|
|
218
|
+
solver_converged: bool
|
|
219
|
+
finite_values: bool
|
|
220
|
+
diagnostic_samples: int = Field(ge=0)
|
|
221
|
+
effective_growth_rate_omega_pe: float
|
|
222
|
+
early_rms_amplitude_v_m: float = Field(gt=0)
|
|
223
|
+
late_rms_amplitude_v_m: float = Field(gt=0)
|
|
224
|
+
amplitude_ratio: float = Field(gt=0)
|
|
225
|
+
early_window_sample_count: int = Field(ge=0)
|
|
226
|
+
late_window_sample_count: int = Field(ge=0)
|
|
227
|
+
classification: Literal["damped", "ambiguous", "unstable"]
|
|
228
|
+
fundamental_amplitude_initial_v_m: float = Field(ge=0)
|
|
229
|
+
fundamental_amplitude_final_v_m: float = Field(ge=0)
|
|
230
|
+
initial_density_normalized: float = Field(gt=0)
|
|
231
|
+
initial_mean_velocity_normalized: float
|
|
232
|
+
initial_variance_normalized: float = Field(ge=0)
|
|
233
|
+
relative_energy_drift: float = Field(ge=0)
|
|
234
|
+
relative_gauss_residual: float = Field(ge=0)
|
|
235
|
+
relative_charge_imbalance: float = Field(ge=0)
|
|
236
|
+
diagnostic_manifest_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class WarpXPairSummary(StrictModel):
|
|
240
|
+
contract_version: Literal["warpx_picmi_pair_v1"] = WARPX_CONTRACT_VERSION
|
|
241
|
+
postprocessor_version: Literal["warpx_openpmd_v2"] = "warpx_openpmd_v2"
|
|
242
|
+
runtime_warpx_version: str = Field(min_length=1)
|
|
243
|
+
reference: WarpXCaseSummary
|
|
244
|
+
candidate: WarpXCaseSummary
|
|
245
|
+
|
|
246
|
+
@model_validator(mode="after")
|
|
247
|
+
def cases_have_expected_roles(self) -> WarpXPairSummary:
|
|
248
|
+
if self.reference.case_name != "unit_maxwellian_reference":
|
|
249
|
+
raise ValueError("reference summary has the wrong case name")
|
|
250
|
+
if self.candidate.case_name != "symmetric_mixture_candidate":
|
|
251
|
+
raise ValueError("candidate summary has the wrong case name")
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class WarpXQualificationRecord(StrictModel):
|
|
256
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
257
|
+
profile_id: str = Field(min_length=1)
|
|
258
|
+
package_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
259
|
+
expected_warpx_version: str = Field(min_length=1)
|
|
260
|
+
observed_warpx_version: str | None = None
|
|
261
|
+
qualification_kind: Literal["picmi_compile_only"] = "picmi_compile_only"
|
|
262
|
+
compiled_case_hashes: dict[str, str] = Field(default_factory=dict)
|
|
263
|
+
checks: dict[str, bool]
|
|
264
|
+
passed: bool
|
|
265
|
+
scientific_evidence_eligible: Literal[False] = False
|
|
266
|
+
host_architecture: str = Field(default_factory=platform.machine)
|
|
267
|
+
created_at: datetime = Field(default_factory=utc_now)
|
|
268
|
+
|
|
269
|
+
@model_validator(mode="after")
|
|
270
|
+
def pass_matches_checks(self) -> WarpXQualificationRecord:
|
|
271
|
+
if not self.checks:
|
|
272
|
+
raise ValueError("a qualification record requires at least one check")
|
|
273
|
+
if self.passed != all(self.checks.values()):
|
|
274
|
+
raise ValueError("qualification pass flag must equal the conjunction of checks")
|
|
275
|
+
if self.passed and set(self.compiled_case_hashes) != {
|
|
276
|
+
"unit_maxwellian_reference",
|
|
277
|
+
"symmetric_mixture_candidate",
|
|
278
|
+
}:
|
|
279
|
+
raise ValueError("a passing qualification must compile both cases")
|
|
280
|
+
return self
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class WarpXQualifiedScope(StrictModel):
|
|
284
|
+
physical: WarpXPhysicalConfig
|
|
285
|
+
minimum_grid_cells: int = Field(default=64, ge=32)
|
|
286
|
+
minimum_electron_macroparticles_per_cell: int = Field(default=512, ge=16)
|
|
287
|
+
minimum_ion_macroparticles_per_cell: int = Field(default=16, ge=1)
|
|
288
|
+
maximum_time_step_omega_pe: float = Field(default=0.05, gt=0)
|
|
289
|
+
minimum_final_time_omega_pe: float = Field(default=20.0, gt=0)
|
|
290
|
+
maximum_diagnostic_cadence_omega_pe: float = Field(default=0.1, gt=0)
|
|
291
|
+
early_window_start_omega_pe: float = Field(default=0.0, ge=0)
|
|
292
|
+
early_window_end_omega_pe: float = Field(default=4.0, gt=0)
|
|
293
|
+
late_window_start_omega_pe: float = Field(default=14.0, gt=0)
|
|
294
|
+
late_window_end_omega_pe: float = Field(default=18.0, gt=0)
|
|
295
|
+
maximum_damped_ratio_threshold: float = Field(default=0.75, gt=0, lt=1)
|
|
296
|
+
minimum_unstable_ratio_threshold: float = Field(default=1.5, gt=1)
|
|
297
|
+
maximum_poisson_relative_tolerance: float = Field(default=1.0e-10, gt=0)
|
|
298
|
+
minimum_poisson_max_iterations: int = Field(default=200, ge=10)
|
|
299
|
+
minimum_diagnostic_samples: int = Field(default=50, ge=3)
|
|
300
|
+
minimum_window_samples: int = Field(default=20, ge=3)
|
|
301
|
+
maximum_relative_energy_drift: float = Field(default=0.01, gt=0)
|
|
302
|
+
maximum_relative_gauss_residual: float = Field(default=0.01, gt=0)
|
|
303
|
+
maximum_relative_charge_imbalance: float = Field(default=0.01, gt=0)
|
|
304
|
+
maximum_moment_tolerance: float = Field(default=0.05, gt=0)
|
|
305
|
+
minimum_outcome_tolerance_omega_pe: float = Field(default=0.02, gt=0)
|
|
306
|
+
|
|
307
|
+
def validation_errors(
|
|
308
|
+
self,
|
|
309
|
+
physical: WarpXPhysicalConfig,
|
|
310
|
+
numerical: WarpXNumericalConfig,
|
|
311
|
+
) -> tuple[str, ...]:
|
|
312
|
+
errors: list[str] = []
|
|
313
|
+
if physical != self.physical:
|
|
314
|
+
errors.append("physical configuration is outside the qualified scope")
|
|
315
|
+
if numerical.grid_cells < self.minimum_grid_cells:
|
|
316
|
+
errors.append("grid resolution is below the qualified scope")
|
|
317
|
+
if (
|
|
318
|
+
numerical.electron_macroparticles_per_cell
|
|
319
|
+
< self.minimum_electron_macroparticles_per_cell
|
|
320
|
+
):
|
|
321
|
+
errors.append("electron particle resolution is below the qualified scope")
|
|
322
|
+
if numerical.ion_macroparticles_per_cell < self.minimum_ion_macroparticles_per_cell:
|
|
323
|
+
errors.append("ion particle resolution is below the qualified scope")
|
|
324
|
+
if numerical.time_step_omega_pe > self.maximum_time_step_omega_pe:
|
|
325
|
+
errors.append("time step is above the qualified scope")
|
|
326
|
+
if numerical.final_time_omega_pe < self.minimum_final_time_omega_pe:
|
|
327
|
+
errors.append("run duration is below the qualified scope")
|
|
328
|
+
cadence = numerical.diagnostic_interval_steps * numerical.time_step_omega_pe
|
|
329
|
+
if cadence > self.maximum_diagnostic_cadence_omega_pe:
|
|
330
|
+
errors.append("diagnostic cadence is coarser than the qualified scope")
|
|
331
|
+
configured_windows = (
|
|
332
|
+
numerical.early_window_start_omega_pe,
|
|
333
|
+
numerical.early_window_end_omega_pe,
|
|
334
|
+
numerical.late_window_start_omega_pe,
|
|
335
|
+
numerical.late_window_end_omega_pe,
|
|
336
|
+
)
|
|
337
|
+
qualified_windows = (
|
|
338
|
+
self.early_window_start_omega_pe,
|
|
339
|
+
self.early_window_end_omega_pe,
|
|
340
|
+
self.late_window_start_omega_pe,
|
|
341
|
+
self.late_window_end_omega_pe,
|
|
342
|
+
)
|
|
343
|
+
if any(
|
|
344
|
+
abs(configured - qualified) > 1.0e-12
|
|
345
|
+
for configured, qualified in zip(configured_windows, qualified_windows, strict=True)
|
|
346
|
+
):
|
|
347
|
+
errors.append("growth windows differ from the qualified scope")
|
|
348
|
+
if numerical.damped_ratio_threshold > self.maximum_damped_ratio_threshold:
|
|
349
|
+
errors.append("damped classification gate is weaker than the qualified scope")
|
|
350
|
+
if numerical.unstable_ratio_threshold < self.minimum_unstable_ratio_threshold:
|
|
351
|
+
errors.append("unstable classification gate is weaker than the qualified scope")
|
|
352
|
+
if numerical.poisson_relative_tolerance > self.maximum_poisson_relative_tolerance:
|
|
353
|
+
errors.append("Poisson tolerance is weaker than the qualified scope")
|
|
354
|
+
if numerical.poisson_max_iterations < self.minimum_poisson_max_iterations:
|
|
355
|
+
errors.append("Poisson iteration limit is below the qualified scope")
|
|
356
|
+
if numerical.minimum_diagnostic_samples < self.minimum_diagnostic_samples:
|
|
357
|
+
errors.append("diagnostic sample gate is weaker than the qualified scope")
|
|
358
|
+
if numerical.minimum_window_samples < self.minimum_window_samples:
|
|
359
|
+
errors.append("window sample gate is weaker than the qualified scope")
|
|
360
|
+
if numerical.maximum_relative_energy_drift > self.maximum_relative_energy_drift:
|
|
361
|
+
errors.append("energy-drift gate is weaker than the qualified scope")
|
|
362
|
+
if numerical.maximum_relative_gauss_residual > self.maximum_relative_gauss_residual:
|
|
363
|
+
errors.append("Gauss-residual gate is weaker than the qualified scope")
|
|
364
|
+
if numerical.maximum_relative_charge_imbalance > self.maximum_relative_charge_imbalance:
|
|
365
|
+
errors.append("charge-imbalance gate is weaker than the qualified scope")
|
|
366
|
+
if numerical.moment_tolerance > self.maximum_moment_tolerance:
|
|
367
|
+
errors.append("moment gate is weaker than the qualified scope")
|
|
368
|
+
if numerical.outcome_tolerance_omega_pe < self.minimum_outcome_tolerance_omega_pe:
|
|
369
|
+
errors.append("outcome-separation gate is weaker than the qualified scope")
|
|
370
|
+
return tuple(errors)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class WarpXCalibrationPoint(StrictModel):
|
|
374
|
+
seed: int = Field(ge=1)
|
|
375
|
+
resolution_id: str = Field(min_length=1)
|
|
376
|
+
grid_cells: int = Field(ge=32)
|
|
377
|
+
electron_macroparticles_per_cell: int = Field(ge=16)
|
|
378
|
+
time_step_omega_pe: float = Field(gt=0)
|
|
379
|
+
run_package_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
380
|
+
normalized_result_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
381
|
+
execution_profile: WarpXExecutionProfile
|
|
382
|
+
numerical_validity_passed: bool
|
|
383
|
+
reference_growth_rate: float
|
|
384
|
+
candidate_growth_rate: float
|
|
385
|
+
reference_amplitude_ratio: float = Field(gt=0)
|
|
386
|
+
candidate_amplitude_ratio: float = Field(gt=0)
|
|
387
|
+
reference_classification: Literal["damped", "ambiguous", "unstable"]
|
|
388
|
+
candidate_classification: Literal["damped", "ambiguous", "unstable"]
|
|
389
|
+
reference_diagnostic_manifest_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
390
|
+
candidate_diagnostic_manifest_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class WarpXPhysicsQualificationRecord(StrictModel):
|
|
394
|
+
schema_version: Literal["0.2.0"] = "0.2.0"
|
|
395
|
+
qualification_kind: Literal["known_mode_matrix_v2"] = "known_mode_matrix_v2"
|
|
396
|
+
compile_qualification: WarpXQualificationRecord
|
|
397
|
+
calibration_points: tuple[WarpXCalibrationPoint, ...] = Field(min_length=1)
|
|
398
|
+
postprocessor_version: Literal["warpx_openpmd_v2"] = "warpx_openpmd_v2"
|
|
399
|
+
scope: WarpXQualifiedScope
|
|
400
|
+
analytic_reference_growth_rate: float
|
|
401
|
+
analytic_candidate_growth_rate: float
|
|
402
|
+
expected_reference_classification: Literal["damped"] = "damped"
|
|
403
|
+
expected_candidate_classification: Literal["unstable"] = "unstable"
|
|
404
|
+
minimum_seed_count: int = Field(default=4, ge=2)
|
|
405
|
+
minimum_resolution_count: int = Field(default=2, ge=2)
|
|
406
|
+
minimum_effective_rate_separation: float = Field(default=0.1, gt=0)
|
|
407
|
+
maximum_median_rate_shift: float = Field(default=0.03, gt=0)
|
|
408
|
+
median_reference_rates_by_resolution: dict[str, float]
|
|
409
|
+
median_candidate_rates_by_resolution: dict[str, float]
|
|
410
|
+
checks: dict[str, bool]
|
|
411
|
+
passed: bool
|
|
412
|
+
authorizes_scientific_evidence: bool
|
|
413
|
+
created_at: datetime = Field(default_factory=utc_now)
|
|
414
|
+
|
|
415
|
+
@model_validator(mode="after")
|
|
416
|
+
def status_is_coherent(self) -> WarpXPhysicsQualificationRecord:
|
|
417
|
+
if not self.checks:
|
|
418
|
+
raise ValueError("physics qualification requires checks")
|
|
419
|
+
if self.passed != all(self.checks.values()):
|
|
420
|
+
raise ValueError("physics qualification pass flag must equal all checks")
|
|
421
|
+
if self.authorizes_scientific_evidence != self.passed:
|
|
422
|
+
raise ValueError("only a passing physics qualification can authorize evidence")
|
|
423
|
+
return self
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def warpx_physics_qualification_hash(record: WarpXPhysicsQualificationRecord) -> str:
|
|
427
|
+
def without_timestamps(value: Any) -> Any:
|
|
428
|
+
if isinstance(value, dict):
|
|
429
|
+
return {
|
|
430
|
+
key: without_timestamps(item) for key, item in value.items() if key != "created_at"
|
|
431
|
+
}
|
|
432
|
+
if isinstance(value, list):
|
|
433
|
+
return [without_timestamps(item) for item in value]
|
|
434
|
+
return value
|
|
435
|
+
|
|
436
|
+
return _sha256_text(_canonical_json(without_timestamps(record.model_dump(mode="json"))))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def build_warpx_physics_qualification(
|
|
440
|
+
*,
|
|
441
|
+
compile_qualification: WarpXQualificationRecord,
|
|
442
|
+
calibration_results: Sequence[NormalizedResult],
|
|
443
|
+
scope: WarpXQualifiedScope,
|
|
444
|
+
analytic_reference_growth_rate: float,
|
|
445
|
+
analytic_candidate_growth_rate: float,
|
|
446
|
+
minimum_seed_count: int = 4,
|
|
447
|
+
minimum_resolution_count: int = 2,
|
|
448
|
+
minimum_effective_rate_separation: float = 0.1,
|
|
449
|
+
maximum_median_rate_shift: float = 0.03,
|
|
450
|
+
) -> WarpXPhysicsQualificationRecord:
|
|
451
|
+
points: list[WarpXCalibrationPoint] = []
|
|
452
|
+
physical_configs: list[WarpXPhysicalConfig] = []
|
|
453
|
+
numerical_configs: list[WarpXNumericalConfig] = []
|
|
454
|
+
for result in calibration_results:
|
|
455
|
+
diagnostics = result.diagnostics
|
|
456
|
+
pair = WarpXPairSummary.model_validate(diagnostics["pair_summary"])
|
|
457
|
+
profile = WarpXExecutionProfile.model_validate(diagnostics["execution_profile"])
|
|
458
|
+
physical = WarpXPhysicalConfig.model_validate(diagnostics["physical"])
|
|
459
|
+
numerical = WarpXNumericalConfig.model_validate(diagnostics["numerical"])
|
|
460
|
+
resolution_id = (
|
|
461
|
+
f"g{numerical.grid_cells}_ppc{numerical.electron_macroparticles_per_cell}_"
|
|
462
|
+
f"dt{numerical.time_step_omega_pe:g}"
|
|
463
|
+
)
|
|
464
|
+
physical_configs.append(physical)
|
|
465
|
+
numerical_configs.append(numerical)
|
|
466
|
+
points.append(
|
|
467
|
+
WarpXCalibrationPoint(
|
|
468
|
+
seed=numerical.random_seed,
|
|
469
|
+
resolution_id=resolution_id,
|
|
470
|
+
grid_cells=numerical.grid_cells,
|
|
471
|
+
electron_macroparticles_per_cell=(numerical.electron_macroparticles_per_cell),
|
|
472
|
+
time_step_omega_pe=numerical.time_step_omega_pe,
|
|
473
|
+
run_package_hash=str(diagnostics["run_package_hash"]),
|
|
474
|
+
normalized_result_hash=_sha256_text(
|
|
475
|
+
_canonical_json(result.model_dump(mode="json"))
|
|
476
|
+
),
|
|
477
|
+
execution_profile=profile,
|
|
478
|
+
numerical_validity_passed=bool(
|
|
479
|
+
diagnostics["validity_gates"]["numerical_validity_passed"]
|
|
480
|
+
),
|
|
481
|
+
reference_growth_rate=pair.reference.effective_growth_rate_omega_pe,
|
|
482
|
+
candidate_growth_rate=pair.candidate.effective_growth_rate_omega_pe,
|
|
483
|
+
reference_amplitude_ratio=pair.reference.amplitude_ratio,
|
|
484
|
+
candidate_amplitude_ratio=pair.candidate.amplitude_ratio,
|
|
485
|
+
reference_classification=pair.reference.classification,
|
|
486
|
+
candidate_classification=pair.candidate.classification,
|
|
487
|
+
reference_diagnostic_manifest_hash=(pair.reference.diagnostic_manifest_hash),
|
|
488
|
+
candidate_diagnostic_manifest_hash=(pair.candidate.diagnostic_manifest_hash),
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
seed_set = {point.seed for point in points}
|
|
493
|
+
resolution_set = {point.resolution_id for point in points}
|
|
494
|
+
matrix_keys = {(point.seed, point.resolution_id) for point in points}
|
|
495
|
+
matrix_complete = (
|
|
496
|
+
len(seed_set) >= minimum_seed_count
|
|
497
|
+
and len(resolution_set) >= minimum_resolution_count
|
|
498
|
+
and len(matrix_keys) == len(points) == len(seed_set) * len(resolution_set)
|
|
499
|
+
and all(
|
|
500
|
+
{point.seed for point in points if point.resolution_id == resolution_id} == seed_set
|
|
501
|
+
for resolution_id in resolution_set
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def medians(attribute: str) -> dict[str, float]:
|
|
506
|
+
return {
|
|
507
|
+
resolution_id: float(
|
|
508
|
+
median(
|
|
509
|
+
getattr(point, attribute)
|
|
510
|
+
for point in points
|
|
511
|
+
if point.resolution_id == resolution_id
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
for resolution_id in sorted(resolution_set)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
reference_medians = medians("reference_growth_rate")
|
|
518
|
+
candidate_medians = medians("candidate_growth_rate")
|
|
519
|
+
|
|
520
|
+
def median_range(values: dict[str, float]) -> float:
|
|
521
|
+
return max(values.values()) - min(values.values()) if values else math.inf
|
|
522
|
+
|
|
523
|
+
manifest_hashes = {
|
|
524
|
+
digest
|
|
525
|
+
for point in points
|
|
526
|
+
for digest in (
|
|
527
|
+
point.reference_diagnostic_manifest_hash,
|
|
528
|
+
point.candidate_diagnostic_manifest_hash,
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
checks = {
|
|
532
|
+
"compile_qualification_passed": compile_qualification.passed,
|
|
533
|
+
"compile_package_is_in_calibration_matrix": (
|
|
534
|
+
compile_qualification.package_hash in {point.run_package_hash for point in points}
|
|
535
|
+
),
|
|
536
|
+
"calibration_matrix_complete": matrix_complete,
|
|
537
|
+
"calibration_packages_are_unique": (
|
|
538
|
+
len({point.run_package_hash for point in points}) == len(points)
|
|
539
|
+
),
|
|
540
|
+
"calibration_diagnostics_are_unique": len(manifest_hashes) == 2 * len(points),
|
|
541
|
+
"calibration_profiles_were_unqualified": all(
|
|
542
|
+
not point.execution_profile.qualified_for_scientific_evidence for point in points
|
|
543
|
+
),
|
|
544
|
+
"calibration_runners_are_not_fixtures": all(
|
|
545
|
+
point.execution_profile.runner_kind is not WarpXRunnerKind.CONTRACT_FIXTURE
|
|
546
|
+
for point in points
|
|
547
|
+
),
|
|
548
|
+
"calibration_configurations_are_in_scope": all(
|
|
549
|
+
physical == scope.physical and not scope.validation_errors(physical, numerical)
|
|
550
|
+
for physical, numerical in zip(physical_configs, numerical_configs, strict=True)
|
|
551
|
+
),
|
|
552
|
+
"all_calibration_runs_are_numerically_valid": all(
|
|
553
|
+
point.numerical_validity_passed for point in points
|
|
554
|
+
),
|
|
555
|
+
"all_reference_cases_match_damped_oracle": all(
|
|
556
|
+
point.reference_classification == "damped" for point in points
|
|
557
|
+
),
|
|
558
|
+
"all_candidate_cases_match_unstable_oracle": all(
|
|
559
|
+
point.candidate_classification == "unstable" for point in points
|
|
560
|
+
),
|
|
561
|
+
"all_pairs_exceed_effective_rate_separation": all(
|
|
562
|
+
point.candidate_growth_rate - point.reference_growth_rate
|
|
563
|
+
>= minimum_effective_rate_separation
|
|
564
|
+
for point in points
|
|
565
|
+
),
|
|
566
|
+
"reference_median_rate_is_resolution_converged": (
|
|
567
|
+
median_range(reference_medians) <= maximum_median_rate_shift
|
|
568
|
+
),
|
|
569
|
+
"candidate_median_rate_is_resolution_converged": (
|
|
570
|
+
median_range(candidate_medians) <= maximum_median_rate_shift
|
|
571
|
+
),
|
|
572
|
+
}
|
|
573
|
+
passed = all(checks.values())
|
|
574
|
+
return WarpXPhysicsQualificationRecord(
|
|
575
|
+
compile_qualification=compile_qualification,
|
|
576
|
+
calibration_points=tuple(
|
|
577
|
+
sorted(points, key=lambda point: (point.resolution_id, point.seed))
|
|
578
|
+
),
|
|
579
|
+
scope=scope,
|
|
580
|
+
analytic_reference_growth_rate=analytic_reference_growth_rate,
|
|
581
|
+
analytic_candidate_growth_rate=analytic_candidate_growth_rate,
|
|
582
|
+
minimum_seed_count=minimum_seed_count,
|
|
583
|
+
minimum_resolution_count=minimum_resolution_count,
|
|
584
|
+
minimum_effective_rate_separation=minimum_effective_rate_separation,
|
|
585
|
+
maximum_median_rate_shift=maximum_median_rate_shift,
|
|
586
|
+
median_reference_rates_by_resolution=reference_medians,
|
|
587
|
+
median_candidate_rates_by_resolution=candidate_medians,
|
|
588
|
+
checks=checks,
|
|
589
|
+
passed=passed,
|
|
590
|
+
authorizes_scientific_evidence=passed,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def qualified_warpx_profile(
|
|
595
|
+
record: WarpXPhysicsQualificationRecord,
|
|
596
|
+
*,
|
|
597
|
+
runner_kind: WarpXRunnerKind = WarpXRunnerKind.LOCAL_CPU,
|
|
598
|
+
) -> WarpXExecutionProfile:
|
|
599
|
+
if not record.passed:
|
|
600
|
+
raise ValueError("a failed physics qualification cannot create a profile")
|
|
601
|
+
qualification_hash = warpx_physics_qualification_hash(record)
|
|
602
|
+
return WarpXExecutionProfile(
|
|
603
|
+
profile_id=f"warpx_qualified_{qualification_hash[:16]}",
|
|
604
|
+
runner_kind=runner_kind,
|
|
605
|
+
warpx_version=record.compile_qualification.expected_warpx_version,
|
|
606
|
+
qualification_hash=qualification_hash,
|
|
607
|
+
qualified_for_scientific_evidence=True,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _distribution_components(
|
|
612
|
+
case_name: str, physical: WarpXPhysicalConfig
|
|
613
|
+
) -> tuple[tuple[float, float, float], ...]:
|
|
614
|
+
if case_name == "unit_maxwellian_reference":
|
|
615
|
+
return ((1.0, 0.0, 1.0),)
|
|
616
|
+
candidate = physical.candidate
|
|
617
|
+
return tuple(
|
|
618
|
+
(component.weight, component.drift, component.sigma)
|
|
619
|
+
for component in candidate.distribution().components
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _picmi_script(
|
|
624
|
+
case_name: Literal["unit_maxwellian_reference", "symmetric_mixture_candidate"],
|
|
625
|
+
physical: WarpXPhysicalConfig,
|
|
626
|
+
numerical: WarpXNumericalConfig,
|
|
627
|
+
) -> str:
|
|
628
|
+
"""Render a fixed PICMI program containing validated numeric literals only."""
|
|
629
|
+
|
|
630
|
+
omega_pe = physical.plasma_frequency_rad_s
|
|
631
|
+
dt_s = numerical.time_step_omega_pe / omega_pe
|
|
632
|
+
components = _distribution_components(case_name, physical)
|
|
633
|
+
ppc = numerical.electron_macroparticles_per_cell // len(components)
|
|
634
|
+
component_rows = _canonical_json(
|
|
635
|
+
[
|
|
636
|
+
{
|
|
637
|
+
"density": weight * physical.reference_density_m3,
|
|
638
|
+
"drift": drift * physical.velocity_unit_m_s,
|
|
639
|
+
"sigma": sigma * physical.velocity_unit_m_s,
|
|
640
|
+
}
|
|
641
|
+
for weight, drift, sigma in components
|
|
642
|
+
]
|
|
643
|
+
)
|
|
644
|
+
return f'''#!/usr/bin/env python3
|
|
645
|
+
"""Generated from a typed source specification; edit that specification, not this file."""
|
|
646
|
+
|
|
647
|
+
import argparse
|
|
648
|
+
from pywarpx import picmi
|
|
649
|
+
|
|
650
|
+
CASE_NAME = {case_name!r}
|
|
651
|
+
COMPONENTS = {component_rows}
|
|
652
|
+
|
|
653
|
+
grid = picmi.Cartesian1DGrid(
|
|
654
|
+
number_of_cells=[{numerical.grid_cells}],
|
|
655
|
+
lower_bound=[0.0],
|
|
656
|
+
upper_bound=[{physical.domain_length_m!r}],
|
|
657
|
+
lower_boundary_conditions=["periodic"],
|
|
658
|
+
upper_boundary_conditions=["periodic"],
|
|
659
|
+
lower_boundary_conditions_particles=["periodic"],
|
|
660
|
+
upper_boundary_conditions_particles=["periodic"],
|
|
661
|
+
warpx_max_grid_size={min(64, numerical.grid_cells)},
|
|
662
|
+
)
|
|
663
|
+
solver = picmi.ElectrostaticSolver(
|
|
664
|
+
grid=grid,
|
|
665
|
+
required_precision={numerical.poisson_relative_tolerance!r},
|
|
666
|
+
maximum_iterations={numerical.poisson_max_iterations},
|
|
667
|
+
)
|
|
668
|
+
simulation = picmi.Simulation(
|
|
669
|
+
solver=solver,
|
|
670
|
+
time_step_size={dt_s!r},
|
|
671
|
+
max_steps={numerical.max_steps},
|
|
672
|
+
particle_shape=2,
|
|
673
|
+
verbose=1,
|
|
674
|
+
warpx_random_seed={numerical.random_seed},
|
|
675
|
+
warpx_serialize_initial_conditions=True,
|
|
676
|
+
warpx_used_inputs_file="warpx_used_inputs",
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
electrons = []
|
|
680
|
+
for index, component in enumerate(COMPONENTS):
|
|
681
|
+
distribution = picmi.AnalyticDistribution(
|
|
682
|
+
density_expression=(
|
|
683
|
+
f"{{component['density']}}*(1+{physical.perturbation_amplitude!r}*"
|
|
684
|
+
f"cos({physical.wavenumber_m_inverse!r}*z))"
|
|
685
|
+
),
|
|
686
|
+
directed_velocity=[0.0, 0.0, component["drift"]],
|
|
687
|
+
rms_velocity=[0.0, 0.0, component["sigma"]],
|
|
688
|
+
)
|
|
689
|
+
species = picmi.Species(
|
|
690
|
+
particle_type="electron",
|
|
691
|
+
name=f"electrons_{{index}}",
|
|
692
|
+
initial_distribution=distribution,
|
|
693
|
+
)
|
|
694
|
+
electrons.append(species)
|
|
695
|
+
simulation.add_species(
|
|
696
|
+
species,
|
|
697
|
+
layout=picmi.GriddedLayout(
|
|
698
|
+
grid=grid,
|
|
699
|
+
n_macroparticle_per_cell=[{ppc}],
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
ions = picmi.Species(
|
|
704
|
+
particle_type="proton",
|
|
705
|
+
name="immobile_ions",
|
|
706
|
+
initial_distribution=picmi.UniformDistribution(
|
|
707
|
+
density={physical.reference_density_m3!r},
|
|
708
|
+
directed_velocity=[0.0, 0.0, 0.0],
|
|
709
|
+
),
|
|
710
|
+
warpx_do_not_push=True,
|
|
711
|
+
)
|
|
712
|
+
simulation.add_species(
|
|
713
|
+
ions,
|
|
714
|
+
layout=picmi.GriddedLayout(
|
|
715
|
+
grid=grid,
|
|
716
|
+
n_macroparticle_per_cell=[{numerical.ion_macroparticles_per_cell}],
|
|
717
|
+
),
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
simulation.add_diagnostic(
|
|
721
|
+
picmi.FieldDiagnostic(
|
|
722
|
+
name="fields",
|
|
723
|
+
grid=grid,
|
|
724
|
+
period={numerical.diagnostic_interval_steps},
|
|
725
|
+
data_list=["E", "rho"],
|
|
726
|
+
warpx_format="openpmd",
|
|
727
|
+
warpx_openpmd_backend="h5",
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
simulation.add_diagnostic(
|
|
731
|
+
picmi.ParticleDiagnostic(
|
|
732
|
+
name="particles",
|
|
733
|
+
period={numerical.max_steps},
|
|
734
|
+
species=electrons,
|
|
735
|
+
data_list=["weighting", "momentum", "position"],
|
|
736
|
+
warpx_format="openpmd",
|
|
737
|
+
warpx_openpmd_backend="h5",
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
simulation.add_diagnostic(
|
|
741
|
+
picmi.ReducedDiagnostic(diag_type="FieldEnergy", name="field_energy", period=1)
|
|
742
|
+
)
|
|
743
|
+
simulation.add_diagnostic(
|
|
744
|
+
picmi.ReducedDiagnostic(diag_type="ParticleEnergy", name="particle_energy", period=1)
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
parser = argparse.ArgumentParser()
|
|
748
|
+
parser.add_argument("--compile-only", action="store_true")
|
|
749
|
+
arguments = parser.parse_args()
|
|
750
|
+
if arguments.compile_only:
|
|
751
|
+
simulation.write_input_file(file_name="inputs")
|
|
752
|
+
else:
|
|
753
|
+
simulation.step()
|
|
754
|
+
'''
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def build_warpx_experiment(
|
|
758
|
+
candidate: SymmetricMixtureCandidate | None = None,
|
|
759
|
+
*,
|
|
760
|
+
physical: WarpXPhysicalConfig | None = None,
|
|
761
|
+
numerical: WarpXNumericalConfig | None = None,
|
|
762
|
+
experiment_id: str = "experiment_warpx_moment_sufficiency_v1",
|
|
763
|
+
) -> ExperimentSpec:
|
|
764
|
+
chosen = candidate or (
|
|
765
|
+
physical.candidate
|
|
766
|
+
if physical
|
|
767
|
+
else SymmetricMixtureCandidate(inner_pair_weight=0.5, inner_drift=0.95, outer_drift=0.95)
|
|
768
|
+
)
|
|
769
|
+
base_physical = physical or WarpXPhysicalConfig(
|
|
770
|
+
inner_pair_weight=chosen.inner_pair_weight,
|
|
771
|
+
inner_drift=chosen.inner_drift,
|
|
772
|
+
outer_drift=chosen.outer_drift,
|
|
773
|
+
)
|
|
774
|
+
if base_physical.candidate != chosen:
|
|
775
|
+
raise ValueError("candidate and physical configuration disagree")
|
|
776
|
+
numerical = numerical or WarpXNumericalConfig()
|
|
777
|
+
return ExperimentSpec(
|
|
778
|
+
id=experiment_id,
|
|
779
|
+
hypothesis_ids=("hypothesis_low_moments_sufficient_for_stability",),
|
|
780
|
+
action_type=WARPX_ACTION,
|
|
781
|
+
physical_parameters=base_physical.model_dump(mode="json"),
|
|
782
|
+
numerical_parameters=numerical.model_dump(mode="json"),
|
|
783
|
+
required_diagnostics=WARPX_REQUIRED_DIAGNOSTICS,
|
|
784
|
+
predictions={
|
|
785
|
+
"sufficiency": "matched distributions have equal normalized growth rate",
|
|
786
|
+
"counterexample": "matched distributions differ in normalized growth rate",
|
|
787
|
+
},
|
|
788
|
+
falsification_condition=(
|
|
789
|
+
"both cases pass numerical gates and normalized growth rates differ by more than "
|
|
790
|
+
f"{numerical.outcome_tolerance_omega_pe}"
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
class SubprocessWarpXScheduler:
|
|
796
|
+
"""Durable local runner contract used by qualification and site wrappers.
|
|
797
|
+
|
|
798
|
+
The configured command receives ``--package``, ``--result`` and
|
|
799
|
+
``--work-directory``. It must write one ``WarpXPairSummary`` JSON object.
|
|
800
|
+
The scheduler is synchronous for now, but job identity and state are stored
|
|
801
|
+
on disk so controller restarts reattach instead of creating a second run.
|
|
802
|
+
"""
|
|
803
|
+
|
|
804
|
+
def __init__(
|
|
805
|
+
self,
|
|
806
|
+
*,
|
|
807
|
+
work_root: Path,
|
|
808
|
+
command: tuple[str, ...],
|
|
809
|
+
profile: WarpXExecutionProfile,
|
|
810
|
+
timeout_seconds: float = 3600.0,
|
|
811
|
+
) -> None:
|
|
812
|
+
if not command:
|
|
813
|
+
raise ValueError("runner command cannot be empty")
|
|
814
|
+
self.work_root = Path(work_root).resolve()
|
|
815
|
+
self.command = command
|
|
816
|
+
self.profile = profile
|
|
817
|
+
self.timeout_seconds = timeout_seconds
|
|
818
|
+
|
|
819
|
+
def _job_id(self, run: RunPackage, idempotency_key: str) -> str:
|
|
820
|
+
identity = _sha256_text(f"{run.package_hash}:{idempotency_key}")
|
|
821
|
+
return f"warpx-{identity[:20]}"
|
|
822
|
+
|
|
823
|
+
def _job_dir(self, job_id: str) -> Path:
|
|
824
|
+
return self.work_root / job_id
|
|
825
|
+
|
|
826
|
+
@staticmethod
|
|
827
|
+
def _archive_interrupted_outputs(job_dir: Path) -> None:
|
|
828
|
+
"""Preserve partial outputs before replaying a controller-orphaned job."""
|
|
829
|
+
|
|
830
|
+
for name in ("execution", "pair_summary.json", "pair_summary.json.tmp"):
|
|
831
|
+
source = job_dir / name
|
|
832
|
+
if not source.exists():
|
|
833
|
+
continue
|
|
834
|
+
ordinal = 1
|
|
835
|
+
while (job_dir / f"interrupted_{ordinal}_{name}").exists():
|
|
836
|
+
ordinal += 1
|
|
837
|
+
source.rename(job_dir / f"interrupted_{ordinal}_{name}")
|
|
838
|
+
|
|
839
|
+
def _commit_summary(
|
|
840
|
+
self,
|
|
841
|
+
*,
|
|
842
|
+
job_id: str,
|
|
843
|
+
run: RunPackage,
|
|
844
|
+
summary: WarpXPairSummary,
|
|
845
|
+
raw_path: Path,
|
|
846
|
+
status_path: Path,
|
|
847
|
+
) -> None:
|
|
848
|
+
result_payload = {
|
|
849
|
+
"experiment_id": run.experiment_id,
|
|
850
|
+
"run_package_hash": run.package_hash,
|
|
851
|
+
"physical": run.payload["physical"],
|
|
852
|
+
"numerical": run.payload["numerical"],
|
|
853
|
+
"execution_profile": self.profile.model_dump(mode="json"),
|
|
854
|
+
"pair_summary": summary.model_dump(mode="json"),
|
|
855
|
+
}
|
|
856
|
+
result_hash = _sha256_text(_canonical_json(result_payload))
|
|
857
|
+
raw = RawResult(
|
|
858
|
+
job_id=job_id,
|
|
859
|
+
payload=result_payload,
|
|
860
|
+
artifact_hashes=(result_hash,),
|
|
861
|
+
)
|
|
862
|
+
raw_path.write_text(raw.model_dump_json(indent=2) + "\n")
|
|
863
|
+
status_path.write_text(
|
|
864
|
+
json.dumps(
|
|
865
|
+
{
|
|
866
|
+
"state": JobState.COMPLETED.value,
|
|
867
|
+
"package_hash": run.package_hash,
|
|
868
|
+
"detail": "runner produced a schema-valid pair summary",
|
|
869
|
+
},
|
|
870
|
+
indent=2,
|
|
871
|
+
sort_keys=True,
|
|
872
|
+
)
|
|
873
|
+
+ "\n"
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
def submit(self, run: RunPackage, *, idempotency_key: str) -> JobReference:
|
|
877
|
+
job_id = self._job_id(run, idempotency_key)
|
|
878
|
+
job_dir = self._job_dir(job_id)
|
|
879
|
+
job_dir.mkdir(parents=True, exist_ok=True)
|
|
880
|
+
status_path = job_dir / "status.json"
|
|
881
|
+
raw_path = job_dir / "raw_result.json"
|
|
882
|
+
result_path = job_dir / "pair_summary.json"
|
|
883
|
+
lock_path = job_dir / "dispatch.lock"
|
|
884
|
+
with lock_path.open("a+") as dispatch_lock:
|
|
885
|
+
# The runner inherits this descriptor. After an accidental
|
|
886
|
+
# controller stop, another controller waits for the still-running
|
|
887
|
+
# child, then commits its completed result or safely replays it.
|
|
888
|
+
fcntl.flock(dispatch_lock.fileno(), fcntl.LOCK_EX)
|
|
889
|
+
if status_path.exists():
|
|
890
|
+
status = json.loads(status_path.read_text())
|
|
891
|
+
if status["package_hash"] != run.package_hash:
|
|
892
|
+
raise ValueError("stored job has a different run-package hash")
|
|
893
|
+
if status["state"] in {JobState.COMPLETED.value, JobState.FAILED.value}:
|
|
894
|
+
return JobReference(
|
|
895
|
+
job_id=job_id,
|
|
896
|
+
experiment_id=run.experiment_id,
|
|
897
|
+
idempotency_key=idempotency_key,
|
|
898
|
+
)
|
|
899
|
+
if result_path.exists():
|
|
900
|
+
try:
|
|
901
|
+
orphaned_summary = WarpXPairSummary.model_validate_json(
|
|
902
|
+
result_path.read_text()
|
|
903
|
+
)
|
|
904
|
+
except ValueError:
|
|
905
|
+
pass
|
|
906
|
+
else:
|
|
907
|
+
self._commit_summary(
|
|
908
|
+
job_id=job_id,
|
|
909
|
+
run=run,
|
|
910
|
+
summary=orphaned_summary,
|
|
911
|
+
raw_path=raw_path,
|
|
912
|
+
status_path=status_path,
|
|
913
|
+
)
|
|
914
|
+
return JobReference(
|
|
915
|
+
job_id=job_id,
|
|
916
|
+
experiment_id=run.experiment_id,
|
|
917
|
+
idempotency_key=idempotency_key,
|
|
918
|
+
)
|
|
919
|
+
self._archive_interrupted_outputs(job_dir)
|
|
920
|
+
|
|
921
|
+
package_path = job_dir / "run_package.json"
|
|
922
|
+
package_path.write_text(run.model_dump_json(indent=2) + "\n")
|
|
923
|
+
status_path.write_text(
|
|
924
|
+
json.dumps(
|
|
925
|
+
{
|
|
926
|
+
"state": JobState.RUNNING.value,
|
|
927
|
+
"package_hash": run.package_hash,
|
|
928
|
+
"detail": "local subprocess owns the durable dispatch lock",
|
|
929
|
+
},
|
|
930
|
+
indent=2,
|
|
931
|
+
sort_keys=True,
|
|
932
|
+
)
|
|
933
|
+
+ "\n"
|
|
934
|
+
)
|
|
935
|
+
command = (
|
|
936
|
+
*self.command,
|
|
937
|
+
"--package",
|
|
938
|
+
str(package_path),
|
|
939
|
+
"--result",
|
|
940
|
+
str(result_path),
|
|
941
|
+
"--work-directory",
|
|
942
|
+
str(job_dir),
|
|
943
|
+
)
|
|
944
|
+
try:
|
|
945
|
+
completed = subprocess.run(
|
|
946
|
+
command,
|
|
947
|
+
cwd=job_dir,
|
|
948
|
+
capture_output=True,
|
|
949
|
+
text=True,
|
|
950
|
+
timeout=self.timeout_seconds,
|
|
951
|
+
check=False,
|
|
952
|
+
pass_fds=(dispatch_lock.fileno(),),
|
|
953
|
+
)
|
|
954
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
955
|
+
detail = f"{type(error).__name__}: {error}"
|
|
956
|
+
status_path.write_text(
|
|
957
|
+
json.dumps(
|
|
958
|
+
{
|
|
959
|
+
"state": JobState.FAILED.value,
|
|
960
|
+
"package_hash": run.package_hash,
|
|
961
|
+
"detail": detail,
|
|
962
|
+
},
|
|
963
|
+
indent=2,
|
|
964
|
+
sort_keys=True,
|
|
965
|
+
)
|
|
966
|
+
+ "\n"
|
|
967
|
+
)
|
|
968
|
+
else:
|
|
969
|
+
if completed.returncode != 0 or not result_path.exists():
|
|
970
|
+
detail = (
|
|
971
|
+
f"runner exit={completed.returncode}; stderr={completed.stderr[-4000:]}"
|
|
972
|
+
)
|
|
973
|
+
status_path.write_text(
|
|
974
|
+
json.dumps(
|
|
975
|
+
{
|
|
976
|
+
"state": JobState.FAILED.value,
|
|
977
|
+
"package_hash": run.package_hash,
|
|
978
|
+
"detail": detail,
|
|
979
|
+
},
|
|
980
|
+
indent=2,
|
|
981
|
+
sort_keys=True,
|
|
982
|
+
)
|
|
983
|
+
+ "\n"
|
|
984
|
+
)
|
|
985
|
+
else:
|
|
986
|
+
try:
|
|
987
|
+
summary = WarpXPairSummary.model_validate_json(result_path.read_text())
|
|
988
|
+
except ValueError as error:
|
|
989
|
+
status_path.write_text(
|
|
990
|
+
json.dumps(
|
|
991
|
+
{
|
|
992
|
+
"state": JobState.FAILED.value,
|
|
993
|
+
"package_hash": run.package_hash,
|
|
994
|
+
"detail": f"runner result failed schema validation: {error}",
|
|
995
|
+
},
|
|
996
|
+
indent=2,
|
|
997
|
+
sort_keys=True,
|
|
998
|
+
)
|
|
999
|
+
+ "\n"
|
|
1000
|
+
)
|
|
1001
|
+
else:
|
|
1002
|
+
self._commit_summary(
|
|
1003
|
+
job_id=job_id,
|
|
1004
|
+
run=run,
|
|
1005
|
+
summary=summary,
|
|
1006
|
+
raw_path=raw_path,
|
|
1007
|
+
status_path=status_path,
|
|
1008
|
+
)
|
|
1009
|
+
return JobReference(
|
|
1010
|
+
job_id=job_id,
|
|
1011
|
+
experiment_id=run.experiment_id,
|
|
1012
|
+
idempotency_key=idempotency_key,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
def monitor(self, job: JobReference) -> JobStatus:
|
|
1016
|
+
status_path = self._job_dir(job.job_id) / "status.json"
|
|
1017
|
+
if not status_path.exists():
|
|
1018
|
+
return JobStatus(job_id=job.job_id, state=JobState.UNKNOWN)
|
|
1019
|
+
payload = json.loads(status_path.read_text())
|
|
1020
|
+
return JobStatus(
|
|
1021
|
+
job_id=job.job_id,
|
|
1022
|
+
state=JobState(payload["state"]),
|
|
1023
|
+
detail=str(payload.get("detail", "")),
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
def retrieve(self, job: JobReference) -> RawResult:
|
|
1027
|
+
path = self._job_dir(job.job_id) / "raw_result.json"
|
|
1028
|
+
if not path.exists():
|
|
1029
|
+
raise LookupError(f"WarpX job {job.job_id} has no completed result")
|
|
1030
|
+
result = RawResult.model_validate_json(path.read_text())
|
|
1031
|
+
if result.job_id != job.job_id or result.payload.get("experiment_id") != job.experiment_id:
|
|
1032
|
+
raise ValueError("stored WarpX result identity does not match the job reference")
|
|
1033
|
+
return result
|
|
1034
|
+
|
|
1035
|
+
def cancel(self, job: JobReference) -> JobStatus:
|
|
1036
|
+
status = self.monitor(job)
|
|
1037
|
+
if status.state in {JobState.COMPLETED, JobState.FAILED, JobState.UNKNOWN}:
|
|
1038
|
+
return status
|
|
1039
|
+
return JobStatus(
|
|
1040
|
+
job_id=job.job_id,
|
|
1041
|
+
state=JobState.UNKNOWN,
|
|
1042
|
+
detail="the synchronous local runner cannot be safely cancelled cross-process",
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
class WarpXAdapter:
|
|
1047
|
+
_PHYSICAL_KEYS = set(WarpXPhysicalConfig.model_fields)
|
|
1048
|
+
_NUMERICAL_KEYS = set(WarpXNumericalConfig.model_fields)
|
|
1049
|
+
|
|
1050
|
+
def __init__(
|
|
1051
|
+
self,
|
|
1052
|
+
scheduler: SubprocessWarpXScheduler,
|
|
1053
|
+
*,
|
|
1054
|
+
physics_qualification: WarpXPhysicsQualificationRecord | None = None,
|
|
1055
|
+
) -> None:
|
|
1056
|
+
self.scheduler = scheduler
|
|
1057
|
+
self.physics_qualification = physics_qualification
|
|
1058
|
+
if scheduler.profile.qualified_for_scientific_evidence:
|
|
1059
|
+
if physics_qualification is None or not physics_qualification.passed:
|
|
1060
|
+
raise ValueError("a qualified execution profile requires its passing record")
|
|
1061
|
+
if scheduler.profile.qualification_hash != warpx_physics_qualification_hash(
|
|
1062
|
+
physics_qualification
|
|
1063
|
+
):
|
|
1064
|
+
raise ValueError("execution profile qualification hash does not match its record")
|
|
1065
|
+
elif physics_qualification is not None:
|
|
1066
|
+
raise ValueError("an unqualified execution profile cannot carry a physics record")
|
|
1067
|
+
|
|
1068
|
+
def capabilities(self) -> CapabilityManifest:
|
|
1069
|
+
return CapabilityManifest(
|
|
1070
|
+
adapter_name="warpx_picmi",
|
|
1071
|
+
adapter_version=WARPX_ADAPTER_VERSION,
|
|
1072
|
+
supported_actions=(WARPX_ACTION,),
|
|
1073
|
+
supported_models=(WARPX_MODEL,),
|
|
1074
|
+
supported_diagnostics=WARPX_REQUIRED_DIAGNOSTICS,
|
|
1075
|
+
supported_coordinates=("density", "mean_velocity", "variance"),
|
|
1076
|
+
supported_observable_kinds=("effective_fundamental_growth_rate",),
|
|
1077
|
+
supports_checkpoint=True,
|
|
1078
|
+
deterministic=False,
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
def _configs(
|
|
1082
|
+
self, experiment: ExperimentSpec
|
|
1083
|
+
) -> tuple[WarpXPhysicalConfig, WarpXNumericalConfig]:
|
|
1084
|
+
return (
|
|
1085
|
+
WarpXPhysicalConfig.model_validate(experiment.physical_parameters),
|
|
1086
|
+
WarpXNumericalConfig.model_validate(experiment.numerical_parameters),
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
def validate(self, experiment: ExperimentSpec) -> ValidationReport:
|
|
1090
|
+
errors: list[str] = []
|
|
1091
|
+
warnings: list[str] = []
|
|
1092
|
+
if experiment.action_type != WARPX_ACTION:
|
|
1093
|
+
errors.append("unsupported action_type")
|
|
1094
|
+
unknown_physical = set(experiment.physical_parameters) - self._PHYSICAL_KEYS
|
|
1095
|
+
unknown_numerical = set(experiment.numerical_parameters) - self._NUMERICAL_KEYS
|
|
1096
|
+
if unknown_physical:
|
|
1097
|
+
errors.append(f"unknown physical parameters: {sorted(unknown_physical)}")
|
|
1098
|
+
if unknown_numerical:
|
|
1099
|
+
errors.append(f"unknown numerical parameters: {sorted(unknown_numerical)}")
|
|
1100
|
+
missing = set(WARPX_REQUIRED_DIAGNOSTICS) - set(experiment.required_diagnostics)
|
|
1101
|
+
extra = set(experiment.required_diagnostics) - set(WARPX_REQUIRED_DIAGNOSTICS)
|
|
1102
|
+
if missing:
|
|
1103
|
+
errors.append(f"missing required diagnostics: {sorted(missing)}")
|
|
1104
|
+
if extra:
|
|
1105
|
+
errors.append(f"unsupported diagnostics: {sorted(extra)}")
|
|
1106
|
+
if not errors:
|
|
1107
|
+
try:
|
|
1108
|
+
physical, numerical = self._configs(experiment)
|
|
1109
|
+
except ValueError as error:
|
|
1110
|
+
errors.append(f"invalid WarpX configuration: {error}")
|
|
1111
|
+
else:
|
|
1112
|
+
if self.physics_qualification is not None:
|
|
1113
|
+
errors.extend(
|
|
1114
|
+
self.physics_qualification.scope.validation_errors(
|
|
1115
|
+
physical,
|
|
1116
|
+
numerical,
|
|
1117
|
+
)
|
|
1118
|
+
)
|
|
1119
|
+
if numerical.grid_cells * numerical.electron_macroparticles_per_cell > 1e7:
|
|
1120
|
+
warnings.append("the paired run initializes more than ten million electrons")
|
|
1121
|
+
cell_size = physical.domain_length_m / numerical.grid_cells
|
|
1122
|
+
thermal_crossing = physical.velocity_unit_m_s * (
|
|
1123
|
+
numerical.time_step_omega_pe / physical.plasma_frequency_rad_s
|
|
1124
|
+
)
|
|
1125
|
+
if thermal_crossing / cell_size > 0.5:
|
|
1126
|
+
warnings.append("thermal particles cross more than half a cell per step")
|
|
1127
|
+
return ValidationReport(valid=not errors, errors=tuple(errors), warnings=tuple(warnings))
|
|
1128
|
+
|
|
1129
|
+
def estimate_cost(self, experiment: ExperimentSpec) -> CostEstimate:
|
|
1130
|
+
report = self.validate(experiment)
|
|
1131
|
+
if not report.valid:
|
|
1132
|
+
raise ValueError("; ".join(report.errors))
|
|
1133
|
+
_, numerical = self._configs(experiment)
|
|
1134
|
+
particles_per_case = numerical.grid_cells * (
|
|
1135
|
+
numerical.electron_macroparticles_per_cell + numerical.ion_macroparticles_per_cell
|
|
1136
|
+
)
|
|
1137
|
+
work = 2 * particles_per_case * numerical.max_steps
|
|
1138
|
+
snapshots = 2 * (numerical.max_steps // numerical.diagnostic_interval_steps + 1)
|
|
1139
|
+
return CostEstimate(
|
|
1140
|
+
compute_units=work / 100_000_000,
|
|
1141
|
+
wall_seconds=work / 5_000_000,
|
|
1142
|
+
storage_bytes=snapshots * numerical.grid_cells * 512,
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
def compile_input(self, experiment: ExperimentSpec) -> RunPackage:
|
|
1146
|
+
report = self.validate(experiment)
|
|
1147
|
+
if not report.valid:
|
|
1148
|
+
raise ValueError("; ".join(report.errors))
|
|
1149
|
+
physical, numerical = self._configs(experiment)
|
|
1150
|
+
cases = tuple(
|
|
1151
|
+
WarpXCompiledCase(
|
|
1152
|
+
case_name=case_name,
|
|
1153
|
+
script=script.strip(),
|
|
1154
|
+
script_hash=_sha256_text(script.strip()),
|
|
1155
|
+
)
|
|
1156
|
+
for case_name in (
|
|
1157
|
+
"unit_maxwellian_reference",
|
|
1158
|
+
"symmetric_mixture_candidate",
|
|
1159
|
+
)
|
|
1160
|
+
for script in (_picmi_script(case_name, physical, numerical),)
|
|
1161
|
+
)
|
|
1162
|
+
payload = {
|
|
1163
|
+
"contract_version": WARPX_CONTRACT_VERSION,
|
|
1164
|
+
"compiler": "restricted_picmi_template_v2",
|
|
1165
|
+
"expected_warpx_version": self.scheduler.profile.warpx_version,
|
|
1166
|
+
"physical": physical.model_dump(mode="json"),
|
|
1167
|
+
"numerical": numerical.model_dump(mode="json"),
|
|
1168
|
+
"cases": [case.model_dump(mode="json") for case in cases],
|
|
1169
|
+
}
|
|
1170
|
+
return RunPackage(
|
|
1171
|
+
experiment_id=experiment.id,
|
|
1172
|
+
adapter_name="warpx_picmi",
|
|
1173
|
+
payload=payload,
|
|
1174
|
+
package_hash=_sha256_text(_canonical_json(payload)),
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
def submit(self, run: RunPackage, *, idempotency_key: str) -> JobReference:
|
|
1178
|
+
if run.adapter_name != "warpx_picmi":
|
|
1179
|
+
raise ValueError("run package belongs to a different adapter")
|
|
1180
|
+
return self.scheduler.submit(run, idempotency_key=idempotency_key)
|
|
1181
|
+
|
|
1182
|
+
def monitor(self, job: JobReference) -> JobStatus:
|
|
1183
|
+
return self.scheduler.monitor(job)
|
|
1184
|
+
|
|
1185
|
+
def retrieve(self, job: JobReference) -> RawResult:
|
|
1186
|
+
return self.scheduler.retrieve(job)
|
|
1187
|
+
|
|
1188
|
+
def normalize(self, result: RawResult) -> NormalizedResult:
|
|
1189
|
+
payload = result.payload
|
|
1190
|
+
expected_artifact_hash = _sha256_text(_canonical_json(payload))
|
|
1191
|
+
if result.artifact_hashes != (expected_artifact_hash,):
|
|
1192
|
+
raise ValueError("WarpX raw-result artifact hash does not match its payload")
|
|
1193
|
+
physical = WarpXPhysicalConfig.model_validate(payload["physical"])
|
|
1194
|
+
numerical = WarpXNumericalConfig.model_validate(payload["numerical"])
|
|
1195
|
+
profile = WarpXExecutionProfile.model_validate(payload["execution_profile"])
|
|
1196
|
+
summary = WarpXPairSummary.model_validate(payload["pair_summary"])
|
|
1197
|
+
reference = summary.reference
|
|
1198
|
+
candidate = summary.candidate
|
|
1199
|
+
|
|
1200
|
+
def expected_classification(case: WarpXCaseSummary) -> str:
|
|
1201
|
+
if case.amplitude_ratio < numerical.damped_ratio_threshold:
|
|
1202
|
+
return "damped"
|
|
1203
|
+
if case.amplitude_ratio > numerical.unstable_ratio_threshold:
|
|
1204
|
+
return "unstable"
|
|
1205
|
+
return "ambiguous"
|
|
1206
|
+
|
|
1207
|
+
case_gates = {
|
|
1208
|
+
case.case_name: {
|
|
1209
|
+
"solver_converged": case.solver_converged,
|
|
1210
|
+
"finite_values": case.finite_values,
|
|
1211
|
+
"diagnostic_samples": (
|
|
1212
|
+
case.diagnostic_samples >= numerical.minimum_diagnostic_samples
|
|
1213
|
+
),
|
|
1214
|
+
"energy_drift": (
|
|
1215
|
+
case.relative_energy_drift <= numerical.maximum_relative_energy_drift
|
|
1216
|
+
),
|
|
1217
|
+
"gauss_residual": (
|
|
1218
|
+
case.relative_gauss_residual <= numerical.maximum_relative_gauss_residual
|
|
1219
|
+
),
|
|
1220
|
+
"charge_imbalance": (
|
|
1221
|
+
case.relative_charge_imbalance <= numerical.maximum_relative_charge_imbalance
|
|
1222
|
+
),
|
|
1223
|
+
"early_window_samples": (
|
|
1224
|
+
case.early_window_sample_count >= numerical.minimum_window_samples
|
|
1225
|
+
),
|
|
1226
|
+
"late_window_samples": (
|
|
1227
|
+
case.late_window_sample_count >= numerical.minimum_window_samples
|
|
1228
|
+
),
|
|
1229
|
+
"classification_consistent": (case.classification == expected_classification(case)),
|
|
1230
|
+
}
|
|
1231
|
+
for case in (reference, candidate)
|
|
1232
|
+
}
|
|
1233
|
+
moments_match = all(
|
|
1234
|
+
difference <= numerical.moment_tolerance
|
|
1235
|
+
for difference in (
|
|
1236
|
+
abs(reference.initial_density_normalized - candidate.initial_density_normalized),
|
|
1237
|
+
abs(
|
|
1238
|
+
reference.initial_mean_velocity_normalized
|
|
1239
|
+
- candidate.initial_mean_velocity_normalized
|
|
1240
|
+
),
|
|
1241
|
+
abs(reference.initial_variance_normalized - candidate.initial_variance_normalized),
|
|
1242
|
+
abs(reference.initial_density_normalized - 1.0),
|
|
1243
|
+
abs(reference.initial_mean_velocity_normalized),
|
|
1244
|
+
abs(reference.initial_variance_normalized - 1.0),
|
|
1245
|
+
abs(candidate.initial_density_normalized - 1.0),
|
|
1246
|
+
abs(candidate.initial_mean_velocity_normalized),
|
|
1247
|
+
abs(candidate.initial_variance_normalized - 1.0),
|
|
1248
|
+
)
|
|
1249
|
+
)
|
|
1250
|
+
runtime_matches = _warpx_versions_equivalent(
|
|
1251
|
+
summary.runtime_warpx_version,
|
|
1252
|
+
profile.warpx_version,
|
|
1253
|
+
)
|
|
1254
|
+
numerical_validity = (
|
|
1255
|
+
all(all(gates.values()) for gates in case_gates.values())
|
|
1256
|
+
and moments_match
|
|
1257
|
+
and runtime_matches
|
|
1258
|
+
)
|
|
1259
|
+
separation = abs(
|
|
1260
|
+
candidate.effective_growth_rate_omega_pe - reference.effective_growth_rate_omega_pe
|
|
1261
|
+
)
|
|
1262
|
+
opposite = {reference.classification, candidate.classification} == {
|
|
1263
|
+
"damped",
|
|
1264
|
+
"unstable",
|
|
1265
|
+
}
|
|
1266
|
+
raw_witness = (
|
|
1267
|
+
numerical_validity and opposite and separation > numerical.outcome_tolerance_omega_pe
|
|
1268
|
+
)
|
|
1269
|
+
eligible = numerical_validity and profile.qualified_for_scientific_evidence
|
|
1270
|
+
diagnostics = {
|
|
1271
|
+
"contract_version": WARPX_CONTRACT_VERSION,
|
|
1272
|
+
"run_package_hash": str(payload["run_package_hash"]),
|
|
1273
|
+
"physical": physical.model_dump(mode="json"),
|
|
1274
|
+
"numerical": numerical.model_dump(mode="json"),
|
|
1275
|
+
"execution_profile": profile.model_dump(mode="json"),
|
|
1276
|
+
"pair_summary": summary.model_dump(mode="json"),
|
|
1277
|
+
"validity_gates": {
|
|
1278
|
+
"cases": case_gates,
|
|
1279
|
+
"moments_match": moments_match,
|
|
1280
|
+
"runtime_version_matches_profile": runtime_matches,
|
|
1281
|
+
"numerical_validity_passed": numerical_validity,
|
|
1282
|
+
},
|
|
1283
|
+
"raw_witness_satisfies_predicate": raw_witness,
|
|
1284
|
+
"scientific_evidence_eligible": eligible,
|
|
1285
|
+
}
|
|
1286
|
+
return NormalizedResult(
|
|
1287
|
+
experiment_id=str(payload["experiment_id"]),
|
|
1288
|
+
observables={
|
|
1289
|
+
"maxwellian_growth_rate": reference.effective_growth_rate_omega_pe,
|
|
1290
|
+
"two_stream_growth_rate": candidate.effective_growth_rate_omega_pe,
|
|
1291
|
+
"moments_match": moments_match,
|
|
1292
|
+
"outcome_separation": separation,
|
|
1293
|
+
"numerical_validity_passed": numerical_validity,
|
|
1294
|
+
"hypothesis_falsified": eligible and raw_witness,
|
|
1295
|
+
},
|
|
1296
|
+
diagnostics=diagnostics,
|
|
1297
|
+
artifact_hashes=result.artifact_hashes,
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
def cancel(self, job: JobReference) -> JobStatus:
|
|
1301
|
+
return self.scheduler.cancel(job)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def qualify_warpx_picmi_compiler(
|
|
1305
|
+
run: RunPackage,
|
|
1306
|
+
*,
|
|
1307
|
+
python_executable: Path,
|
|
1308
|
+
work_directory: Path,
|
|
1309
|
+
profile_id: str = "warpx_cpu_26_07_compile",
|
|
1310
|
+
) -> WarpXQualificationRecord:
|
|
1311
|
+
"""Import WarpX and compile both generated PICMI cases without evolving them."""
|
|
1312
|
+
|
|
1313
|
+
expected_version = str(run.payload["expected_warpx_version"])
|
|
1314
|
+
python_executable = python_executable.resolve()
|
|
1315
|
+
work_directory = work_directory.resolve()
|
|
1316
|
+
checks: dict[str, bool] = {}
|
|
1317
|
+
compiled: dict[str, str] = {}
|
|
1318
|
+
observed_version: str | None = None
|
|
1319
|
+
work_directory.mkdir(parents=True, exist_ok=True)
|
|
1320
|
+
try:
|
|
1321
|
+
version = subprocess.run(
|
|
1322
|
+
[
|
|
1323
|
+
str(python_executable),
|
|
1324
|
+
"-c",
|
|
1325
|
+
"import importlib.metadata; print(importlib.metadata.version('pywarpx'))",
|
|
1326
|
+
],
|
|
1327
|
+
capture_output=True,
|
|
1328
|
+
text=True,
|
|
1329
|
+
timeout=60,
|
|
1330
|
+
check=False,
|
|
1331
|
+
)
|
|
1332
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1333
|
+
checks["runtime_import"] = False
|
|
1334
|
+
checks["runtime_version"] = False
|
|
1335
|
+
else:
|
|
1336
|
+
observed_version = version.stdout.strip() if version.returncode == 0 else None
|
|
1337
|
+
checks["runtime_import"] = version.returncode == 0
|
|
1338
|
+
checks["runtime_version"] = bool(observed_version) and _warpx_versions_equivalent(
|
|
1339
|
+
observed_version,
|
|
1340
|
+
expected_version,
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
for case_payload in run.payload["cases"]:
|
|
1344
|
+
case = WarpXCompiledCase.model_validate(case_payload)
|
|
1345
|
+
case_dir = work_directory / case.case_name
|
|
1346
|
+
case_dir.mkdir(parents=True, exist_ok=True)
|
|
1347
|
+
script_path = case_dir / "run_picmi.py"
|
|
1348
|
+
script_path.write_text(case.script)
|
|
1349
|
+
try:
|
|
1350
|
+
execution = subprocess.run(
|
|
1351
|
+
[str(python_executable), str(script_path), "--compile-only"],
|
|
1352
|
+
cwd=case_dir,
|
|
1353
|
+
capture_output=True,
|
|
1354
|
+
text=True,
|
|
1355
|
+
timeout=180,
|
|
1356
|
+
check=False,
|
|
1357
|
+
)
|
|
1358
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1359
|
+
checks[f"compile_{case.case_name}"] = False
|
|
1360
|
+
continue
|
|
1361
|
+
input_path = case_dir / "inputs"
|
|
1362
|
+
success = execution.returncode == 0 and input_path.exists()
|
|
1363
|
+
checks[f"compile_{case.case_name}"] = success
|
|
1364
|
+
if success:
|
|
1365
|
+
compiled[case.case_name] = hashlib.sha256(input_path.read_bytes()).hexdigest()
|
|
1366
|
+
|
|
1367
|
+
return WarpXQualificationRecord(
|
|
1368
|
+
profile_id=profile_id,
|
|
1369
|
+
package_hash=run.package_hash,
|
|
1370
|
+
expected_warpx_version=expected_version,
|
|
1371
|
+
observed_warpx_version=observed_version,
|
|
1372
|
+
compiled_case_hashes=compiled,
|
|
1373
|
+
checks=checks,
|
|
1374
|
+
passed=bool(checks) and all(checks.values()),
|
|
1375
|
+
)
|