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,547 @@
|
|
|
1
|
+
"""Independent one-dimensional electrostatic particle-in-cell benchmark.
|
|
2
|
+
|
|
3
|
+
The solver uses cloud-in-cell deposition/interpolation, a spectral periodic
|
|
4
|
+
Poisson solve, leapfrog integration, and quiet-start velocity beams. It is an
|
|
5
|
+
independent numerical check of the analytic dispersion-relation benchmark.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from pydantic import Field, model_validator
|
|
16
|
+
from scipy.optimize import brentq
|
|
17
|
+
from scipy.special import ndtr, ndtri
|
|
18
|
+
|
|
19
|
+
from ..models import (
|
|
20
|
+
DomainSpec,
|
|
21
|
+
EvidenceContract,
|
|
22
|
+
ExperimentSpec,
|
|
23
|
+
HypothesisNode,
|
|
24
|
+
HypothesisOrigin,
|
|
25
|
+
MatchedPairFormalPredicate,
|
|
26
|
+
ObservableSpec,
|
|
27
|
+
PropositionClass,
|
|
28
|
+
StrictModel,
|
|
29
|
+
)
|
|
30
|
+
from .kinetic_sufficiency import (
|
|
31
|
+
DistributionMoments,
|
|
32
|
+
GaussianMixture,
|
|
33
|
+
moments,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PICDistribution(StrEnum):
|
|
38
|
+
MAXWELLIAN = "maxwellian"
|
|
39
|
+
SYMMETRIC_TWO_STREAM = "symmetric_two_stream"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PICNumericalConfig(StrictModel):
|
|
43
|
+
"""Distribution-independent PIC discretization and validity contract."""
|
|
44
|
+
|
|
45
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
46
|
+
wavenumber: float = Field(default=0.5, gt=0)
|
|
47
|
+
grid_cells: int = Field(default=64, ge=32)
|
|
48
|
+
velocity_beams: int = Field(default=256, ge=64)
|
|
49
|
+
particles_per_beam: int = Field(default=64, ge=32)
|
|
50
|
+
time_step: float = Field(default=0.05, gt=0)
|
|
51
|
+
final_time: float = Field(default=20.0, gt=0)
|
|
52
|
+
diagnostic_interval: float = Field(default=0.5, gt=0)
|
|
53
|
+
perturbation_amplitude: float = Field(default=0.01, gt=0, lt=0.1)
|
|
54
|
+
seed: int = Field(default=7, ge=0)
|
|
55
|
+
early_window: tuple[float, float] = (0.0, 4.0)
|
|
56
|
+
late_window: tuple[float, float] = (14.0, 18.0)
|
|
57
|
+
damped_ratio_threshold: float = Field(default=0.75, gt=0, lt=1)
|
|
58
|
+
unstable_ratio_threshold: float = Field(default=1.5, gt=1)
|
|
59
|
+
maximum_relative_energy_drift: float = Field(default=1e-3, gt=0)
|
|
60
|
+
maximum_gauss_residual: float = Field(default=1e-10, gt=0)
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def validate_discretization(self) -> PICNumericalConfig:
|
|
64
|
+
if abs(self.wavenumber - 0.5) > 1e-12:
|
|
65
|
+
raise ValueError("PIC benchmark version 1 is preregistered at k=0.5")
|
|
66
|
+
if self.grid_cells & (self.grid_cells - 1):
|
|
67
|
+
raise ValueError("grid_cells must be a power of two")
|
|
68
|
+
if self.velocity_beams % 2:
|
|
69
|
+
raise ValueError("velocity_beams must be even")
|
|
70
|
+
steps = self.final_time / self.time_step
|
|
71
|
+
stride = self.diagnostic_interval / self.time_step
|
|
72
|
+
if abs(steps - round(steps)) > 1e-10:
|
|
73
|
+
raise ValueError("final_time must contain an integer number of steps")
|
|
74
|
+
if abs(stride - round(stride)) > 1e-10:
|
|
75
|
+
raise ValueError("diagnostic_interval must contain an integer number of steps")
|
|
76
|
+
early_start, early_end = self.early_window
|
|
77
|
+
late_start, late_end = self.late_window
|
|
78
|
+
if not 0 <= early_start < early_end < late_start < late_end <= self.final_time:
|
|
79
|
+
raise ValueError("diagnostic windows must be ordered inside the simulated time")
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PICConfig(PICNumericalConfig):
|
|
84
|
+
"""Legacy fixed-pair benchmark configuration."""
|
|
85
|
+
|
|
86
|
+
stream_drift: float = Field(default=0.9, gt=0, lt=1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
DEFAULT_PIC_CONFIG = PICConfig()
|
|
90
|
+
DEFAULT_PIC_NUMERICAL_CONFIG = PICNumericalConfig()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PICTrace(StrictModel):
|
|
94
|
+
times: tuple[float, ...]
|
|
95
|
+
fundamental_mode_amplitudes: tuple[float, ...]
|
|
96
|
+
total_energies: tuple[float, ...]
|
|
97
|
+
gauss_residuals: tuple[float, ...]
|
|
98
|
+
|
|
99
|
+
@model_validator(mode="after")
|
|
100
|
+
def equal_trace_lengths(self) -> PICTrace:
|
|
101
|
+
lengths = {
|
|
102
|
+
len(self.times),
|
|
103
|
+
len(self.fundamental_mode_amplitudes),
|
|
104
|
+
len(self.total_energies),
|
|
105
|
+
len(self.gauss_residuals),
|
|
106
|
+
}
|
|
107
|
+
if len(lengths) != 1 or not self.times:
|
|
108
|
+
raise ValueError("PIC diagnostic traces must be non-empty and aligned")
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PICCaseResult(StrictModel):
|
|
113
|
+
distribution: PICDistribution
|
|
114
|
+
config: PICConfig
|
|
115
|
+
particle_count: int = Field(ge=1)
|
|
116
|
+
initial_moments: DistributionMoments
|
|
117
|
+
early_rms_amplitude: float = Field(gt=0)
|
|
118
|
+
late_rms_amplitude: float = Field(gt=0)
|
|
119
|
+
amplitude_ratio: float = Field(gt=0)
|
|
120
|
+
effective_growth_rate: float
|
|
121
|
+
classification: Literal["damped", "unstable", "ambiguous"]
|
|
122
|
+
relative_energy_drift: float = Field(ge=0)
|
|
123
|
+
maximum_gauss_residual: float = Field(ge=0)
|
|
124
|
+
validity_passed: bool
|
|
125
|
+
trace: PICTrace
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class PICMixtureCaseResult(StrictModel):
|
|
129
|
+
"""PIC result for an explicitly supplied Gaussian mixture."""
|
|
130
|
+
|
|
131
|
+
name: str
|
|
132
|
+
distribution: GaussianMixture
|
|
133
|
+
config: PICNumericalConfig
|
|
134
|
+
particle_count: int = Field(ge=1)
|
|
135
|
+
initial_moments: DistributionMoments
|
|
136
|
+
early_rms_amplitude: float = Field(gt=0)
|
|
137
|
+
late_rms_amplitude: float = Field(gt=0)
|
|
138
|
+
amplitude_ratio: float = Field(gt=0)
|
|
139
|
+
effective_growth_rate: float
|
|
140
|
+
classification: Literal["damped", "unstable", "ambiguous"]
|
|
141
|
+
relative_energy_drift: float = Field(ge=0)
|
|
142
|
+
maximum_gauss_residual: float = Field(ge=0)
|
|
143
|
+
validity_passed: bool
|
|
144
|
+
trace: PICTrace
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class PICSufficiencyResult(StrictModel):
|
|
148
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
149
|
+
hypothesis: HypothesisNode
|
|
150
|
+
observable: ObservableSpec
|
|
151
|
+
maxwellian: PICCaseResult
|
|
152
|
+
two_stream: PICCaseResult
|
|
153
|
+
moments_match: bool
|
|
154
|
+
hypothesis_falsified: bool
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_pic_problem() -> tuple[HypothesisNode, ObservableSpec]:
|
|
158
|
+
domain = DomainSpec(
|
|
159
|
+
id="domain_electrostatic_1d_pic_k05",
|
|
160
|
+
description=(
|
|
161
|
+
"Small-amplitude periodic 1D electrostatic PIC approximations of normalized "
|
|
162
|
+
"electron distributions with immobile ions at k=0.5"
|
|
163
|
+
),
|
|
164
|
+
model_family="electrostatic_1d_pic_vlasov_poisson",
|
|
165
|
+
assumptions=(
|
|
166
|
+
"electron plasma frequency equals one",
|
|
167
|
+
"immobile uniform neutralizing ion background",
|
|
168
|
+
"periodic single-mode domain",
|
|
169
|
+
"quiet-start Gaussian velocity beams",
|
|
170
|
+
),
|
|
171
|
+
fixed_parameters={
|
|
172
|
+
"wavenumber": 0.5,
|
|
173
|
+
"perturbation_amplitude": 0.01,
|
|
174
|
+
"stream_drift": 0.9,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
observable = ObservableSpec(
|
|
178
|
+
id="observable_pic_effective_mode_growth",
|
|
179
|
+
name="PIC effective fundamental-mode envelope growth rate",
|
|
180
|
+
semantic_kind="effective_fundamental_growth_rate",
|
|
181
|
+
mathematical_definition="log(late_mode_rms / early_mode_rms) / time_separation",
|
|
182
|
+
estimator="quiet-start electrostatic PIC Fourier-mode diagnostic",
|
|
183
|
+
units="electron_plasma_frequency",
|
|
184
|
+
tolerance=0.02,
|
|
185
|
+
)
|
|
186
|
+
coordinates = ("density", "mean_velocity", "variance")
|
|
187
|
+
hypothesis = HypothesisNode(
|
|
188
|
+
id="hypothesis_pic_low_moments_sufficient_for_stability",
|
|
189
|
+
statement=(
|
|
190
|
+
"Density, mean velocity, and variance are sufficient to determine the "
|
|
191
|
+
"effective fundamental-mode growth rate in the declared PIC family."
|
|
192
|
+
),
|
|
193
|
+
machine_predicate=(
|
|
194
|
+
"equal(n, mean_v, variance) implies abs(gamma_left-gamma_right) <= 0.02"
|
|
195
|
+
),
|
|
196
|
+
formal_predicate=MatchedPairFormalPredicate(
|
|
197
|
+
matched_coordinates=coordinates,
|
|
198
|
+
outcome_observable_id=observable.id,
|
|
199
|
+
maximum_outcome_difference=observable.tolerance,
|
|
200
|
+
),
|
|
201
|
+
proposition_class=PropositionClass.PREDICTIVE_SUFFICIENCY,
|
|
202
|
+
domain=domain,
|
|
203
|
+
coordinates=coordinates,
|
|
204
|
+
evidence_contract=EvidenceContract(
|
|
205
|
+
primary_observable_id=observable.id,
|
|
206
|
+
falsifying_witness=(
|
|
207
|
+
"a matched-moment pair with PIC effective growth rates separated by "
|
|
208
|
+
"more than 0.02 and opposite stability classifications"
|
|
209
|
+
),
|
|
210
|
+
primary_tolerance=observable.tolerance,
|
|
211
|
+
),
|
|
212
|
+
origin=HypothesisOrigin.HUMAN,
|
|
213
|
+
)
|
|
214
|
+
return hypothesis, observable
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def build_pic_experiment(config: PICConfig = DEFAULT_PIC_CONFIG) -> ExperimentSpec:
|
|
218
|
+
hypothesis, _ = build_pic_problem()
|
|
219
|
+
return ExperimentSpec(
|
|
220
|
+
id="experiment_electrostatic_pic_sufficiency_v1",
|
|
221
|
+
hypothesis_ids=(hypothesis.id,),
|
|
222
|
+
action_type="kinetic_sufficiency",
|
|
223
|
+
physical_parameters={
|
|
224
|
+
"wavenumber": config.wavenumber,
|
|
225
|
+
"perturbation_amplitude": config.perturbation_amplitude,
|
|
226
|
+
"stream_drift": config.stream_drift,
|
|
227
|
+
},
|
|
228
|
+
numerical_parameters={
|
|
229
|
+
"grid_cells": config.grid_cells,
|
|
230
|
+
"velocity_beams": config.velocity_beams,
|
|
231
|
+
"particles_per_beam": config.particles_per_beam,
|
|
232
|
+
"time_step": config.time_step,
|
|
233
|
+
"final_time": config.final_time,
|
|
234
|
+
"diagnostic_interval": config.diagnostic_interval,
|
|
235
|
+
"seed": config.seed,
|
|
236
|
+
},
|
|
237
|
+
required_diagnostics=(
|
|
238
|
+
"dominant_linear_mode",
|
|
239
|
+
"distribution_moments",
|
|
240
|
+
"energy_conservation",
|
|
241
|
+
"gauss_residual",
|
|
242
|
+
),
|
|
243
|
+
predictions={
|
|
244
|
+
"hypothesis_holds": "matched distributions have equal effective growth rate",
|
|
245
|
+
"falsifying_witness": (
|
|
246
|
+
"matched distributions have separated rates and opposite stability classes"
|
|
247
|
+
),
|
|
248
|
+
},
|
|
249
|
+
falsification_condition=(
|
|
250
|
+
"both PIC cases pass validity gates, moments match, classifications are opposite, "
|
|
251
|
+
"and effective rates differ by more than 0.02"
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _velocity_beams(
|
|
257
|
+
distribution: PICDistribution,
|
|
258
|
+
config: PICConfig,
|
|
259
|
+
) -> np.ndarray:
|
|
260
|
+
half = config.velocity_beams // 2
|
|
261
|
+
probabilities = (np.arange(half, dtype=float) + 0.5) / half
|
|
262
|
+
thermal = ndtri(probabilities)
|
|
263
|
+
thermal = (thermal - np.mean(thermal)) / np.std(thermal)
|
|
264
|
+
if distribution is PICDistribution.MAXWELLIAN:
|
|
265
|
+
return np.tile(thermal, 2)
|
|
266
|
+
component_sigma = np.sqrt(1.0 - config.stream_drift**2)
|
|
267
|
+
return np.concatenate(
|
|
268
|
+
(
|
|
269
|
+
-config.stream_drift + component_sigma * thermal,
|
|
270
|
+
config.stream_drift + component_sigma * thermal,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _mixture_velocity_beams(
|
|
276
|
+
distribution: GaussianMixture,
|
|
277
|
+
velocity_beams: int,
|
|
278
|
+
) -> np.ndarray:
|
|
279
|
+
"""Deterministic inverse-CDF beams, rescaled to exact discrete moments."""
|
|
280
|
+
|
|
281
|
+
probabilities = (np.arange(velocity_beams, dtype=float) + 0.5) / velocity_beams
|
|
282
|
+
lower = min(component.drift - 10.0 * component.sigma for component in distribution.components)
|
|
283
|
+
upper = max(component.drift + 10.0 * component.sigma for component in distribution.components)
|
|
284
|
+
|
|
285
|
+
def cdf(value: float) -> float:
|
|
286
|
+
return float(
|
|
287
|
+
sum(
|
|
288
|
+
component.weight
|
|
289
|
+
* ndtr((value - component.drift) / component.sigma)
|
|
290
|
+
for component in distribution.components
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
beams = np.asarray(
|
|
295
|
+
[
|
|
296
|
+
brentq(lambda value, target=target: cdf(value) - target, lower, upper)
|
|
297
|
+
for target in probabilities
|
|
298
|
+
]
|
|
299
|
+
)
|
|
300
|
+
target = moments(distribution)
|
|
301
|
+
beams = (beams - np.mean(beams)) / np.std(beams)
|
|
302
|
+
beams = beams * np.sqrt(target.variance) + target.mean_velocity
|
|
303
|
+
return beams
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _quiet_start_from_beams(
|
|
307
|
+
beams: np.ndarray,
|
|
308
|
+
config: PICNumericalConfig,
|
|
309
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
310
|
+
if beams.shape != (config.velocity_beams,):
|
|
311
|
+
raise ValueError("velocity beam count does not match the PIC configuration")
|
|
312
|
+
length = 2.0 * np.pi / config.wavenumber
|
|
313
|
+
rng = np.random.default_rng(config.seed)
|
|
314
|
+
offsets = rng.random(config.velocity_beams)
|
|
315
|
+
base = np.arange(config.particles_per_beam, dtype=float)
|
|
316
|
+
unperturbed = (
|
|
317
|
+
(base[None, :] + 0.5 + offsets[:, None]) % config.particles_per_beam
|
|
318
|
+
) * (length / config.particles_per_beam)
|
|
319
|
+
positions = (
|
|
320
|
+
unperturbed
|
|
321
|
+
- config.perturbation_amplitude
|
|
322
|
+
/ config.wavenumber
|
|
323
|
+
* np.sin(config.wavenumber * unperturbed)
|
|
324
|
+
) % length
|
|
325
|
+
velocities = np.repeat(beams, config.particles_per_beam)
|
|
326
|
+
return positions.ravel(), velocities
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _deposit_and_field(
|
|
330
|
+
positions: np.ndarray,
|
|
331
|
+
config: PICNumericalConfig,
|
|
332
|
+
) -> tuple[np.ndarray, np.ndarray, float]:
|
|
333
|
+
length = 2.0 * np.pi / config.wavenumber
|
|
334
|
+
cell_width = length / config.grid_cells
|
|
335
|
+
coordinate = positions / cell_width
|
|
336
|
+
left = np.floor(coordinate).astype(np.int64) % config.grid_cells
|
|
337
|
+
fraction = coordinate - left
|
|
338
|
+
density = (
|
|
339
|
+
np.bincount(left, weights=1.0 - fraction, minlength=config.grid_cells)
|
|
340
|
+
+ np.bincount(
|
|
341
|
+
(left + 1) % config.grid_cells,
|
|
342
|
+
weights=fraction,
|
|
343
|
+
minlength=config.grid_cells,
|
|
344
|
+
)
|
|
345
|
+
) * (config.grid_cells / positions.size)
|
|
346
|
+
charge_density = 1.0 - density
|
|
347
|
+
charge_modes = np.fft.fft(charge_density) / config.grid_cells
|
|
348
|
+
wave_numbers = 2.0 * np.pi * np.fft.fftfreq(config.grid_cells, d=cell_width)
|
|
349
|
+
field_modes = np.zeros(config.grid_cells, dtype=complex)
|
|
350
|
+
nonzero = wave_numbers != 0
|
|
351
|
+
field_modes[nonzero] = charge_modes[nonzero] / (1j * wave_numbers[nonzero])
|
|
352
|
+
electric_field = np.fft.ifft(field_modes * config.grid_cells).real
|
|
353
|
+
gauss_residual = float(
|
|
354
|
+
np.max(np.abs(1j * wave_numbers[nonzero] * field_modes[nonzero] - charge_modes[nonzero]))
|
|
355
|
+
)
|
|
356
|
+
return electric_field, field_modes, gauss_residual
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _interpolate_field(
|
|
360
|
+
electric_field: np.ndarray,
|
|
361
|
+
positions: np.ndarray,
|
|
362
|
+
config: PICNumericalConfig,
|
|
363
|
+
) -> np.ndarray:
|
|
364
|
+
length = 2.0 * np.pi / config.wavenumber
|
|
365
|
+
cell_width = length / config.grid_cells
|
|
366
|
+
coordinate = positions / cell_width
|
|
367
|
+
left = np.floor(coordinate).astype(np.int64) % config.grid_cells
|
|
368
|
+
fraction = coordinate - left
|
|
369
|
+
return (
|
|
370
|
+
electric_field[left] * (1.0 - fraction)
|
|
371
|
+
+ electric_field[(left + 1) % config.grid_cells] * fraction
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _window_rms(times: np.ndarray, amplitudes: np.ndarray, window: tuple[float, float]) -> float:
|
|
376
|
+
selected = (times >= window[0]) & (times <= window[1])
|
|
377
|
+
if not np.any(selected):
|
|
378
|
+
raise ValueError("diagnostic window contains no samples")
|
|
379
|
+
return float(np.sqrt(np.mean(amplitudes[selected] ** 2)))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@lru_cache(maxsize=32)
|
|
383
|
+
def run_pic_case(
|
|
384
|
+
distribution: PICDistribution,
|
|
385
|
+
config: PICConfig = DEFAULT_PIC_CONFIG,
|
|
386
|
+
) -> PICCaseResult:
|
|
387
|
+
beams = _velocity_beams(distribution, config)
|
|
388
|
+
positions, initial_velocities = _quiet_start_from_beams(beams, config)
|
|
389
|
+
result = _run_pic_dynamics(positions, initial_velocities, config)
|
|
390
|
+
return PICCaseResult(
|
|
391
|
+
distribution=distribution,
|
|
392
|
+
config=config,
|
|
393
|
+
**result,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _run_pic_dynamics(
|
|
398
|
+
positions: np.ndarray,
|
|
399
|
+
initial_velocities: np.ndarray,
|
|
400
|
+
config: PICNumericalConfig,
|
|
401
|
+
) -> dict[str, object]:
|
|
402
|
+
initial_moments = DistributionMoments(
|
|
403
|
+
density=1.0,
|
|
404
|
+
mean_velocity=float(np.mean(initial_velocities)),
|
|
405
|
+
variance=float(np.var(initial_velocities)),
|
|
406
|
+
)
|
|
407
|
+
electric_field, field_modes, gauss_residual = _deposit_and_field(positions, config)
|
|
408
|
+
velocities_half = initial_velocities - 0.5 * config.time_step * _interpolate_field(
|
|
409
|
+
electric_field,
|
|
410
|
+
positions,
|
|
411
|
+
config,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
step_count = round(config.final_time / config.time_step)
|
|
415
|
+
diagnostic_stride = round(config.diagnostic_interval / config.time_step)
|
|
416
|
+
times: list[float] = []
|
|
417
|
+
amplitudes: list[float] = []
|
|
418
|
+
energies: list[float] = []
|
|
419
|
+
gauss_residuals: list[float] = []
|
|
420
|
+
length = 2.0 * np.pi / config.wavenumber
|
|
421
|
+
|
|
422
|
+
for step in range(step_count + 1):
|
|
423
|
+
if step % diagnostic_stride == 0:
|
|
424
|
+
integer_velocity = velocities_half + 0.5 * config.time_step * _interpolate_field(
|
|
425
|
+
electric_field,
|
|
426
|
+
positions,
|
|
427
|
+
config,
|
|
428
|
+
)
|
|
429
|
+
times.append(step * config.time_step)
|
|
430
|
+
amplitudes.append(float(abs(field_modes[1])))
|
|
431
|
+
energies.append(
|
|
432
|
+
float(
|
|
433
|
+
0.5 * np.mean(integer_velocity**2)
|
|
434
|
+
+ 0.5 * np.mean(electric_field**2)
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
gauss_residuals.append(gauss_residual)
|
|
438
|
+
if step == step_count:
|
|
439
|
+
break
|
|
440
|
+
positions = (positions + config.time_step * velocities_half) % length
|
|
441
|
+
electric_field, field_modes, gauss_residual = _deposit_and_field(positions, config)
|
|
442
|
+
velocities_half -= config.time_step * _interpolate_field(
|
|
443
|
+
electric_field,
|
|
444
|
+
positions,
|
|
445
|
+
config,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
time_array = np.asarray(times)
|
|
449
|
+
amplitude_array = np.asarray(amplitudes)
|
|
450
|
+
energy_array = np.asarray(energies)
|
|
451
|
+
early_rms = _window_rms(time_array, amplitude_array, config.early_window)
|
|
452
|
+
late_rms = _window_rms(time_array, amplitude_array, config.late_window)
|
|
453
|
+
ratio = late_rms / early_rms
|
|
454
|
+
time_separation = (
|
|
455
|
+
sum(config.late_window) / 2.0 - sum(config.early_window) / 2.0
|
|
456
|
+
)
|
|
457
|
+
effective_growth_rate = float(np.log(ratio) / time_separation)
|
|
458
|
+
if ratio < config.damped_ratio_threshold:
|
|
459
|
+
classification: Literal["damped", "unstable", "ambiguous"] = "damped"
|
|
460
|
+
elif ratio > config.unstable_ratio_threshold:
|
|
461
|
+
classification = "unstable"
|
|
462
|
+
else:
|
|
463
|
+
classification = "ambiguous"
|
|
464
|
+
relative_energy_drift = float(
|
|
465
|
+
(np.max(energy_array) - np.min(energy_array)) / energy_array[0]
|
|
466
|
+
)
|
|
467
|
+
finite = all(
|
|
468
|
+
np.all(np.isfinite(values))
|
|
469
|
+
for values in (positions, velocities_half, amplitude_array, energy_array)
|
|
470
|
+
)
|
|
471
|
+
maximum_gauss_residual = max(gauss_residuals)
|
|
472
|
+
validity_passed = (
|
|
473
|
+
finite
|
|
474
|
+
and relative_energy_drift <= config.maximum_relative_energy_drift
|
|
475
|
+
and maximum_gauss_residual <= config.maximum_gauss_residual
|
|
476
|
+
)
|
|
477
|
+
return {
|
|
478
|
+
"particle_count": positions.size,
|
|
479
|
+
"initial_moments": initial_moments,
|
|
480
|
+
"early_rms_amplitude": early_rms,
|
|
481
|
+
"late_rms_amplitude": late_rms,
|
|
482
|
+
"amplitude_ratio": ratio,
|
|
483
|
+
"effective_growth_rate": effective_growth_rate,
|
|
484
|
+
"classification": classification,
|
|
485
|
+
"relative_energy_drift": relative_energy_drift,
|
|
486
|
+
"maximum_gauss_residual": maximum_gauss_residual,
|
|
487
|
+
"validity_passed": validity_passed,
|
|
488
|
+
"trace": PICTrace(
|
|
489
|
+
times=tuple(times),
|
|
490
|
+
fundamental_mode_amplitudes=tuple(amplitudes),
|
|
491
|
+
total_energies=tuple(energies),
|
|
492
|
+
gauss_residuals=tuple(gauss_residuals),
|
|
493
|
+
),
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@lru_cache(maxsize=32)
|
|
498
|
+
def run_pic_mixture_case(
|
|
499
|
+
name: str,
|
|
500
|
+
distribution: GaussianMixture,
|
|
501
|
+
config: PICNumericalConfig = DEFAULT_PIC_NUMERICAL_CONFIG,
|
|
502
|
+
) -> PICMixtureCaseResult:
|
|
503
|
+
"""Run the independent PIC instrument for a frozen mixture candidate."""
|
|
504
|
+
|
|
505
|
+
beams = _mixture_velocity_beams(distribution, config.velocity_beams)
|
|
506
|
+
positions, initial_velocities = _quiet_start_from_beams(beams, config)
|
|
507
|
+
result = _run_pic_dynamics(positions, initial_velocities, config)
|
|
508
|
+
return PICMixtureCaseResult(
|
|
509
|
+
name=name,
|
|
510
|
+
distribution=distribution,
|
|
511
|
+
config=config,
|
|
512
|
+
**result,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def run_pic_sufficiency_benchmark(
|
|
517
|
+
config: PICConfig = DEFAULT_PIC_CONFIG,
|
|
518
|
+
) -> PICSufficiencyResult:
|
|
519
|
+
maxwellian = run_pic_case(PICDistribution.MAXWELLIAN, config)
|
|
520
|
+
two_stream = run_pic_case(PICDistribution.SYMMETRIC_TWO_STREAM, config)
|
|
521
|
+
hypothesis, observable = build_pic_problem()
|
|
522
|
+
left = maxwellian.initial_moments
|
|
523
|
+
right = two_stream.initial_moments
|
|
524
|
+
moments_match = all(
|
|
525
|
+
abs(getattr(left, name) - getattr(right, name)) <= 1e-12
|
|
526
|
+
for name in ("density", "mean_velocity", "variance")
|
|
527
|
+
)
|
|
528
|
+
separated = (
|
|
529
|
+
abs(maxwellian.effective_growth_rate - two_stream.effective_growth_rate)
|
|
530
|
+
> hypothesis.evidence_contract.primary_tolerance
|
|
531
|
+
)
|
|
532
|
+
hypothesis_falsified = (
|
|
533
|
+
moments_match
|
|
534
|
+
and maxwellian.validity_passed
|
|
535
|
+
and two_stream.validity_passed
|
|
536
|
+
and maxwellian.classification == "damped"
|
|
537
|
+
and two_stream.classification == "unstable"
|
|
538
|
+
and separated
|
|
539
|
+
)
|
|
540
|
+
return PICSufficiencyResult(
|
|
541
|
+
hypothesis=hypothesis,
|
|
542
|
+
observable=observable,
|
|
543
|
+
maxwellian=maxwellian,
|
|
544
|
+
two_stream=two_stream,
|
|
545
|
+
moments_match=moments_match,
|
|
546
|
+
hypothesis_falsified=hypothesis_falsified,
|
|
547
|
+
)
|