Skills › Scientific Agent Skills › fluidsim › _schema.py
_schema.py
904 lines · skills/fluidsim/scripts/_schema.py at 5f2bc03
1#!/usr/bin/env python3
2"""Static FluidSim 0.9 configuration schema and scientific guardrails."""
4from __future__ import annotations
6import re
7from collections.abc import Mapping
8from copy import deepcopy
9from typing import Any
11try:
12 from ._profiles import PROFILES
13 from ._common import (
14 MAX_CPU_CORES,
15 MAX_DIMENSION,
16 MAX_GRID_POINTS,
17 MAX_OUTPUT_FILES,
18 MAX_WALL_MINUTES,
19 ToolError,
20 bounded_int,
21 finite_float,
22 require_mapping,
23 require_text,
24 require_text_list,
25 safe_relative_path,
26 safe_slug,
27 validate_keys,
28 )
29except ImportError: # Direct script execution.
30 from _profiles import PROFILES
31 from _common import (
32 MAX_CPU_CORES,
33 MAX_DIMENSION,
34 MAX_GRID_POINTS,
35 MAX_OUTPUT_FILES,
36 MAX_WALL_MINUTES,
37 ToolError,
38 bounded_int,
39 finite_float,
40 require_mapping,
41 require_text,
42 require_text_list,
43 safe_relative_path,
44 safe_slug,
45 validate_keys,
46 )
49FLUIDSIM_VERSION = "0.9.0"
50FLUIDFFT_VERSION = "0.4.5"
51PYFFTW_VERSION = "0.15.1"
52MPI4PY_VERSION = "4.1.2"
53REVIEW_DATE = "2026-10-01"
55# Static allowlist from FluidSim 0.9.0 pyproject entry points. No module name is
56# ever derived from user text.
57SOLVER_IMPORTS = {
58 "ad1d": "fluidsim.solvers.ad1d.solver",
59 "ad1d.pseudo_spect": "fluidsim.solvers.ad1d.pseudo_spect.solver",
60 "burgers1d": "fluidsim.solvers.burgers1d.solver",
61 "burgers1d.skew_sym": "fluidsim.solvers.burgers1d.skew_sym.solver",
62 "models0d.lorenz": "fluidsim.solvers.models0d.lorenz.solver",
63 "models0d.predaprey": "fluidsim.solvers.models0d.predaprey.solver",
64 "nl1d": "fluidsim.solvers.nl1d.solver",
65 "ns2d": "fluidsim.solvers.ns2d.solver",
66 "ns2d.bouss": "fluidsim.solvers.ns2d.bouss.solver",
67 "ns2d.strat": "fluidsim.solvers.ns2d.strat.solver",
68 "ns3d": "fluidsim.solvers.ns3d.solver",
69 "ns3d.bouss": "fluidsim.solvers.ns3d.bouss.solver",
70 "ns3d.strat": "fluidsim.solvers.ns3d.strat.solver",
71 "plate2d": "fluidsim.solvers.plate2d.solver",
72 "sphere.ns2d": "fluidsim.solvers.sphere.ns2d.solver",
73 "sphere.sw1l": "fluidsim.solvers.sphere.sw1l.solver",
74 "sw1l": "fluidsim.solvers.sw1l.solver",
75 "sw1l.exactlin": "fluidsim.solvers.sw1l.exactlin.solver",
76 "sw1l.modified": "fluidsim.solvers.sw1l.modified.solver",
77 "sw1l.onlywaves": "fluidsim.solvers.sw1l.onlywaves.solver",
78 "waves2d": "fluidsim.solvers.waves2d.solver",
79}
81# These Cartesian pseudospectral solvers share the parameter surface validated
82# and rendered by the bundled tools.
83CONFIG_SOLVER_DIMENSIONS = {
84 "ns2d": 2,
85 "ns2d.bouss": 2,
86 "ns2d.strat": 2,
87 "ns3d": 3,
88 "ns3d.bouss": 3,
89 "ns3d.strat": 3,
90 "plate2d": 2,
91 "sw1l": 2,
92 "sw1l.exactlin": 2,
93 "sw1l.modified": 2,
94 "sw1l.onlywaves": 2,
95 "waves2d": 2,
96}
98STATE_FIELD_COUNTS = {
99 "ns2d": (3, 2),
100 "ns2d.bouss": (4, 3),
101 "ns2d.strat": (4, 3),
102 "ns3d": (3, 4),
103 "ns3d.bouss": (4, 5),
104 "ns3d.strat": (4, 5),
105 "plate2d": (3, 3),
106 "sw1l": (4, 4),
107 "sw1l.exactlin": (4, 4),
108 "sw1l.modified": (3, 4),
109 "sw1l.onlywaves": (3, 4),
110 "waves2d": (2, 3),
111}
113TIME_SCHEMES = {
114 "Euler",
115 "Euler_phaseshift",
116 "Euler_phaseshift_random",
117 "RK2",
118 "RK2_phaseshift",
119 "RK2_phaseshift_exact",
120 "RK2_phaseshift_random",
121 "RK2_phaseshift_random_split",
122 "RK2_trapezoid",
123 "RK4",
124}
125FORCING_TYPES = {
126 "in_script",
127 "in_script_coarse",
128 "pseudo_spectral",
129 "proportional",
130 "tcrandom",
131 "tcrandom_anisotropic",
132}
133INIT_TYPES = {
134 "constant",
135 "dipole",
136 "from_file",
137 "from_simul",
138 "in_script",
139 "jet",
140 "noise",
141}
142_DURATION = re.compile(r"^(?:\d{1,4}):[0-5]\d:[0-5]\d$")
144TOP_LEVEL_KEYS = {
145 "schema_version",
146 "solver",
147 "parameters",
148 "scientific",
149 "resources",
150 "execution",
151 "provenance",
152}
153PARAMETER_KEYS = {
154 "NEW_DIR_RESULTS",
155 "ONLY_COARSE_OPER",
156 "N",
157 "beta",
158 "c2",
159 "f",
160 "nu_2",
161 "nu_4",
162 "nu_8",
163 "nu_m4",
164 "oper",
165 "time_stepping",
166 "init_fields",
167 "forcing",
168 "output",
169 "short_name_type_run",
170}
171OPER_KEYS = {
172 "Lx",
173 "Ly",
174 "Lz",
175 "NO_KY0",
176 "NO_SHEAR_MODES",
177 "coef_dealiasing",
178 "nx",
179 "ny",
180 "nz",
181 "truncation_shape",
182 "type_fft",
183}
184TIME_KEYS = {
185 "USE_CFL",
186 "USE_T_END",
187 "cfl_coef",
188 "deltat0",
189 "deltat_max",
190 "it_end",
191 "max_elapsed",
192 "phaseshift_random",
193 "t_end",
194 "type_time_scheme",
195}
196INIT_KEYS = {"type", "constant", "from_file", "noise"}
197FORCING_KEYS = {
198 "enable",
199 "forcing_rate",
200 "key_forced",
201 "nkmax_forcing",
202 "nkmin_forcing",
203 "normalized",
204 "random",
205 "tcrandom",
206 "type",
207}
208OUTPUT_KEYS = {
209 "HAS_TO_SAVE",
210 "ONLINE_PLOT_OK",
211 "period_refresh_plots",
212 "periods_plot",
213 "periods_print",
214 "periods_save",
215 "phys_fields",
216 "sub_directory",
217}
218PERIOD_SAVE_KEYS = {
219 "increments",
220 "phys_fields",
221 "spatial_means",
222 "spatiotemporal_spectra",
223 "spect_energy_budg",
224 "spectra",
225 "spectra_multidim",
226 "temporal_spectra",
227}
230def _record(
231 target: list[dict[str, str]], code: str, message: str
232) -> None:
233 target.append({"code": code, "message": message})
236def _validate_parameter_structure(parameters: Mapping[str, Any]) -> None:
237 validate_keys(
238 parameters, allowed=PARAMETER_KEYS, required={"oper", "time_stepping"}, context="parameters"
239 )
240 nested_specs: tuple[tuple[str, set[str]], ...] = (
241 ("oper", OPER_KEYS),
242 ("time_stepping", TIME_KEYS),
243 ("init_fields", INIT_KEYS),
244 ("forcing", FORCING_KEYS),
245 ("output", OUTPUT_KEYS),
246 )
247 for name, keys in nested_specs:
248 if name in parameters:
249 validate_keys(
250 require_mapping(parameters[name], context=f"parameters.{name}"),
251 allowed=keys,
252 context=f"parameters.{name}",
253 )
255 init = parameters.get("init_fields", {})
256 if "constant" in init:
257 validate_keys(
258 require_mapping(init["constant"], context="init_fields.constant"),
259 allowed={"value"},
260 context="init_fields.constant",
261 )
262 if "from_file" in init:
263 validate_keys(
264 require_mapping(init["from_file"], context="init_fields.from_file"),
265 allowed={"path"},
266 context="init_fields.from_file",
267 )
268 if "noise" in init:
269 validate_keys(
270 require_mapping(init["noise"], context="init_fields.noise"),
271 allowed={"length", "velo_max"},
272 context="init_fields.noise",
273 )
275 forcing = parameters.get("forcing", {})
276 nested_forcing = {
277 "normalized": {"constant_rate_of", "type", "which_root"},
278 "random": {"only_positive"},
279 "tcrandom": {"time_correlation"},
280 }
281 for name, keys in nested_forcing.items():
282 if name in forcing:
283 validate_keys(
284 require_mapping(forcing[name], context=f"forcing.{name}"),
285 allowed=keys,
286 context=f"forcing.{name}",
287 )
289 output = parameters.get("output", {})
290 for name, keys in (
291 ("periods_save", PERIOD_SAVE_KEYS),
292 ("periods_print", {"print_stdout"}),
293 ("periods_plot", {"phys_fields"}),
294 ("phys_fields", {"field_to_plot", "file_with_it"}),
295 ):
296 if name in output:
297 validate_keys(
298 require_mapping(output[name], context=f"output.{name}"),
299 allowed=keys,
300 context=f"output.{name}",
301 )
303 time = parameters["time_stepping"]
304 if "phaseshift_random" in time:
305 validate_keys(
306 require_mapping(
307 time["phaseshift_random"], context="time_stepping.phaseshift_random"
308 ),
309 allowed={"nb_pairs", "nb_steps_compute_new_pair"},
310 context="time_stepping.phaseshift_random",
311 )
314def validate_config(document: Any) -> dict[str, Any]:
315 """Validate a strict FluidSim planning configuration without imports."""
317 config = require_mapping(document, context="configuration")
318 validate_keys(
319 config,
320 allowed=TOP_LEVEL_KEYS,
321 required=TOP_LEVEL_KEYS,
322 context="configuration",
323 )
324 errors: list[dict[str, str]] = []
325 warnings: list[dict[str, str]] = []
327 if config["schema_version"] != "1.1":
328 _record(errors, "schema_version", "schema_version must be '1.1'")
330 solver = require_text(config["solver"], name="solver", maximum=80)
331 if solver not in SOLVER_IMPORTS:
332 _record(errors, "unknown_solver", "solver is not a FluidSim 0.9 entry-point key")
333 if solver not in CONFIG_SOLVER_DIMENSIONS:
334 _record(
335 errors,
336 "unsupported_parameter_profile",
337 "bundled config tools cover Cartesian FluidSim CFD solver profiles only",
338 )
340 parameters = require_mapping(config["parameters"], context="parameters")
341 _validate_parameter_structure(parameters)
342 for path, value in flatten_parameters(parameters):
343 if not (value is None or isinstance(value, (str, bool, int, float))):
344 raise ToolError(f"parameters.{'.'.join(path)} must be a scalar")
345 if isinstance(value, (int, float)) and not isinstance(value, bool):
346 finite_float(value, name=f"parameters.{'.'.join(path)}")
347 profile = PROFILES.get(solver)
348 if profile:
349 for key in {"N", "beta", "c2", "f"} & parameters.keys():
350 if key not in profile["physics"]:
351 _record(errors, "unsupported_solver_parameter", f"{solver} has no {key} parameter")
352 if solver.startswith("ns3d") and "NO_KY0" in parameters["oper"]:
353 _record(errors, "unsupported_solver_parameter", f"{solver} has no oper.NO_KY0 parameter")
354 if solver == "waves2d" and parameters.get("init_fields", {}).get("noise"):
355 _record(errors, "unsupported_solver_parameter", "waves2d noise has a different parameter surface")
356 for key in parameters.get("output", {}).get("periods_save", {}):
357 if key not in profile["outputs"]:
358 _record(errors, "unsupported_solver_output", f"{solver} has no periods_save.{key}")
359 oper = require_mapping(parameters["oper"], context="parameters.oper")
360 time = require_mapping(
361 parameters["time_stepping"], context="parameters.time_stepping"
362 )
364 dimension = CONFIG_SOLVER_DIMENSIONS.get(solver)
365 dimensions: list[int] = []
366 if dimension is not None:
367 required_axes = ("x", "y", "z")[:dimension]
368 for axis in required_axes:
369 n_key, length_key = f"n{axis}", f"L{axis}"
370 if n_key not in oper or length_key not in oper:
371 _record(
372 errors,
373 "incomplete_grid",
374 f"oper requires {n_key} and {length_key} for {solver}",
375 )
376 continue
377 try:
378 size = bounded_int(
379 oper[n_key],
380 name=f"oper.{n_key}",
381 minimum=2,
382 maximum=MAX_DIMENSION,
383 )
384 dimensions.append(size)
385 finite_float(
386 oper[length_key], name=f"oper.{length_key}", minimum=1e-15
387 )
388 except ToolError as exc:
389 _record(errors, "invalid_grid", str(exc))
390 for axis in ("x", "y", "z")[dimension:]:
391 if f"n{axis}" in oper or f"L{axis}" in oper:
392 _record(
393 errors,
394 "extra_grid_axis",
395 f"{solver} does not use Cartesian {axis}-axis grid fields",
396 )
397 if dimensions:
398 points = 1
399 for size in dimensions:
400 points *= size
401 if points > MAX_GRID_POINTS:
402 _record(errors, "grid_too_large", "grid-point product exceeds hard bound")
403 if "coef_dealiasing" not in oper:
404 _record(errors, "missing_dealiasing", "oper.coef_dealiasing must be explicit")
405 else:
406 try:
407 coefficient = finite_float(
408 oper["coef_dealiasing"],
409 name="oper.coef_dealiasing",
410 minimum=1e-12,
411 maximum=1.0,
412 )
413 if coefficient is not None and coefficient > 2 / 3 + 1e-12:
414 _record(
415 warnings,
416 "dealiasing_above_two_thirds",
417 "justify truncation above 2/3 or use a verified phase-shift scheme",
418 )
419 except ToolError as exc:
420 _record(errors, "invalid_dealiasing", str(exc))
421 if not isinstance(oper.get("type_fft", "default"), str):
422 _record(errors, "invalid_fft_method", "oper.type_fft must be a string")
424 required_time = {
425 "USE_CFL",
426 "USE_T_END",
427 "cfl_coef",
428 "deltat0",
429 "deltat_max",
430 "max_elapsed",
431 "t_end",
432 "type_time_scheme",
433 }
434 missing_time = sorted(required_time - set(time))
435 if missing_time:
436 _record(
437 errors,
438 "incomplete_time_bounds",
439 f"time_stepping must explicitly set: {', '.join(missing_time)}",
440 )
441 else:
442 if not isinstance(time["USE_CFL"], bool) or not isinstance(
443 time["USE_T_END"], bool
444 ):
445 _record(errors, "invalid_time_flags", "USE_CFL and USE_T_END must be booleans")
446 try:
447 finite_float(time["deltat0"], name="deltat0", minimum=1e-15)
448 finite_float(time["deltat_max"], name="deltat_max", minimum=1e-15)
449 finite_float(time["t_end"], name="t_end", minimum=0.0)
450 if time["USE_CFL"]:
451 finite_float(
452 time["cfl_coef"], name="cfl_coef", minimum=1e-6, maximum=2.0
453 )
454 except ToolError as exc:
455 _record(errors, "invalid_time_bound", str(exc))
456 if not isinstance(time["max_elapsed"], str) or not _DURATION.fullmatch(
457 time["max_elapsed"]
458 ):
459 _record(
460 errors,
461 "invalid_max_elapsed",
462 "max_elapsed must use bounded HH:MM:SS text",
463 )
464 if not isinstance(time["type_time_scheme"], str) or time["type_time_scheme"] not in TIME_SCHEMES:
465 _record(errors, "unknown_time_scheme", "unsupported FluidSim 0.9 time scheme")
466 if not time["USE_T_END"]:
467 try:
468 bounded_int(time.get("it_end"), name="it_end", minimum=1, maximum=2_000_000)
469 except ToolError as exc:
470 _record(errors, "iteration_termination", str(exc))
471 if isinstance(time["max_elapsed"], str) and _DURATION.fullmatch(time["max_elapsed"]):
472 hours, minutes, seconds = map(int, time["max_elapsed"].split(":"))
473 wall_minutes = config.get("resources", {}).get("wall_time_minutes") if isinstance(config.get("resources"), Mapping) else None
474 elapsed = 3600 * hours + 60 * minutes + seconds
475 if elapsed <= 0 or (isinstance(wall_minutes, int) and elapsed > wall_minutes * 60):
476 _record(errors, "wall_time_mismatch", "max_elapsed must be positive and within declared wall time")
478 for key in ("nu_2", "nu_4", "nu_8", "nu_m4"):
479 if key in parameters:
480 try:
481 finite_float(parameters[key], name=key, minimum=0.0)
482 except ToolError as exc:
483 _record(errors, "invalid_dissipation", str(exc))
485 init = require_mapping(parameters.get("init_fields", {}), context="init_fields")
486 init_type = init.get("type")
487 if not isinstance(init_type, str) or init_type not in (profile["initializations"] if profile else INIT_TYPES):
488 _record(errors, "unknown_initialization", "init_fields.type is not recognized")
489 if init_type == "from_file":
490 try:
491 source = require_mapping(init.get("from_file"), context="from_file")
492 safe_relative_path(
493 source.get("path"),
494 name="init_fields.from_file.path",
495 suffixes={".nc", ".h5", ".hdf5"},
496 allow_nested=True,
497 )
498 except ToolError as exc:
499 _record(errors, "unsafe_restart_path", str(exc))
501 forcing = require_mapping(parameters.get("forcing", {}), context="forcing")
502 forcing_enabled = forcing.get("enable", False)
503 if not isinstance(forcing_enabled, bool):
504 _record(errors, "invalid_forcing_enable", "forcing.enable must be a boolean")
505 if forcing_enabled:
506 if not isinstance(forcing.get("type"), str) or forcing.get("type") not in (profile["forcing"] if profile else FORCING_TYPES):
507 _record(errors, "unknown_forcing", "forcing.type is not recognized")
508 for key in ("forcing_rate", "nkmin_forcing", "nkmax_forcing"):
509 if key not in forcing:
510 _record(errors, "incomplete_forcing", f"forcing.{key} must be explicit")
511 try:
512 finite_float(
513 forcing.get("forcing_rate"), name="forcing_rate", minimum=0.0
514 )
515 minimum_k = finite_float(
516 forcing.get("nkmin_forcing"), name="nkmin_forcing", minimum=0.0
517 )
518 maximum_k = finite_float(
519 forcing.get("nkmax_forcing"), name="nkmax_forcing", minimum=0.0
520 )
521 if (
522 minimum_k is not None
523 and maximum_k is not None
524 and maximum_k < minimum_k
525 ):
526 _record(errors, "forcing_band", "nkmax_forcing is below nkmin_forcing")
527 except ToolError as exc:
528 _record(errors, "invalid_forcing", str(exc))
529 if forcing.get("type") == "tcrandom":
530 tcrandom = forcing.get("tcrandom")
531 if not isinstance(tcrandom, Mapping) or "time_correlation" not in tcrandom:
532 _record(
533 errors,
534 "missing_time_correlation",
535 "forcing.tcrandom.time_correlation must be explicit",
536 )
537 elif tcrandom["time_correlation"] == "based_on_forcing_rate":
538 if forcing.get("forcing_rate") == 0:
539 _record(errors, "invalid_forcing", "rate-derived correlation requires positive forcing_rate")
540 else:
541 try:
542 finite_float(tcrandom["time_correlation"], name="time_correlation", minimum=1e-15)
543 except ToolError as exc:
544 _record(errors, "invalid_time_correlation", str(exc))
546 output = require_mapping(parameters.get("output", {}), context="output")
547 for key in ("HAS_TO_SAVE", "ONLINE_PLOT_OK"):
548 if key not in output or not isinstance(output[key], bool):
549 _record(errors, "output_flag", f"output.{key} must be an explicit boolean")
550 try:
551 safe_slug(output.get("sub_directory"), name="output.sub_directory")
552 except ToolError as exc:
553 _record(errors, "unsafe_output_subdirectory", str(exc))
554 periods_save = output.get("periods_save")
555 if not isinstance(periods_save, Mapping):
556 _record(errors, "missing_output_periods", "output.periods_save must be explicit")
557 else:
558 for key, value in periods_save.items():
559 try:
560 finite_float(value, name=f"periods_save.{key}", minimum=0.0)
561 except ToolError as exc:
562 _record(errors, "invalid_output_period", str(exc))
563 if output.get("ONLINE_PLOT_OK"):
564 _record(
565 warnings,
566 "online_plotting",
567 "disable online plotting for unattended/HPC runs",
568 )
569 if output.get("HAS_TO_SAVE") and isinstance(periods_save, Mapping):
570 if isinstance(periods_save.get("phys_fields", 0.0), (int, float)) and periods_save.get("phys_fields", 0.0) <= 0:
571 _record(
572 warnings,
573 "no_restart_checkpoint",
574 "physical-field saves are disabled, so no new restart checkpoint is planned",
575 )
577 scientific = require_mapping(config["scientific"], context="scientific")
578 scientific_required = {
579 "units_or_nondimensionalization",
580 "boundary_conditions",
581 "initial_conditions",
582 "forcing",
583 "resolution_and_dealiasing",
584 "timestep_and_cfl",
585 "conservation_and_budgets",
586 "convergence_and_refinement",
587 "acceptance_criteria",
588 }
589 validate_keys(
590 scientific,
591 allowed=scientific_required,
592 required=scientific_required,
593 context="scientific",
594 )
595 for key in scientific_required - {"conservation_and_budgets", "acceptance_criteria"}:
596 require_text(scientific[key], name=f"scientific.{key}", maximum=2_000)
597 require_text_list(
598 scientific["conservation_and_budgets"],
599 name="scientific.conservation_and_budgets",
600 )
601 require_text_list(
602 scientific["acceptance_criteria"], name="scientific.acceptance_criteria"
603 )
604 if "periodic" not in scientific["boundary_conditions"].casefold():
605 _record(
606 errors,
607 "boundary_conditions",
608 "Cartesian bundled pseudospectral solver plans must state periodic boundaries",
609 )
611 resources = require_mapping(config["resources"], context="resources")
612 resource_keys = {
613 "cpu_cores",
614 "mpi_ranks",
615 "threads_per_rank",
616 "ram_gib",
617 "disk_gib",
618 "wall_time_minutes",
619 "max_output_files",
620 }
621 validate_keys(
622 resources,
623 allowed=resource_keys,
624 required=resource_keys,
625 context="resources",
626 )
627 try:
628 cores = bounded_int(
629 resources["cpu_cores"],
630 name="cpu_cores",
631 minimum=1,
632 maximum=MAX_CPU_CORES,
633 )
634 ranks = bounded_int(
635 resources["mpi_ranks"],
636 name="mpi_ranks",
637 minimum=1,
638 maximum=MAX_CPU_CORES,
639 )
640 threads = bounded_int(
641 resources["threads_per_rank"],
642 name="threads_per_rank",
643 minimum=1,
644 maximum=MAX_CPU_CORES,
645 )
646 finite_float(resources["ram_gib"], name="ram_gib", minimum=0.001)
647 finite_float(resources["disk_gib"], name="disk_gib", minimum=0.001)
648 bounded_int(
649 resources["wall_time_minutes"],
650 name="wall_time_minutes",
651 minimum=1,
652 maximum=MAX_WALL_MINUTES,
653 )
654 bounded_int(
655 resources["max_output_files"],
656 name="max_output_files",
657 minimum=1,
658 maximum=MAX_OUTPUT_FILES,
659 )
660 if ranks * threads > cores:
661 _record(
662 errors,
663 "cpu_oversubscription",
664 "mpi_ranks * threads_per_rank exceeds cpu_cores",
665 )
666 except ToolError as exc:
667 _record(errors, "invalid_resource_bound", str(exc))
669 execution = require_mapping(config["execution"], context="execution")
670 execution_keys = {"mode", "output_root", "random_seed", "script_name"}
671 validate_keys(
672 execution,
673 allowed=execution_keys,
674 required=execution_keys,
675 context="execution",
676 )
677 mode = execution["mode"]
678 if not isinstance(mode, str) or mode not in {"serial", "mpi-preview"}:
679 _record(errors, "execution_mode", "mode must be serial or mpi-preview")
680 try:
681 safe_slug(execution["output_root"], name="execution.output_root")
682 safe_relative_path(
683 execution["script_name"],
684 name="execution.script_name",
685 suffixes={".py"},
686 )
687 bounded_int(
688 execution["random_seed"],
689 name="random_seed",
690 minimum=0,
691 maximum=2**32 - 1,
692 )
693 except ToolError as exc:
694 _record(errors, "invalid_execution", str(exc))
695 if mode == "serial" and resources.get("mpi_ranks") != 1:
696 _record(errors, "serial_ranks", "serial mode requires mpi_ranks = 1")
697 if mode == "mpi-preview" and isinstance(resources.get("mpi_ranks"), int):
698 if resources["mpi_ranks"] < 2:
699 _record(errors, "mpi_ranks", "mpi-preview requires at least two ranks")
701 provenance = require_mapping(config["provenance"], context="provenance")
702 provenance_keys = {
703 "config_id",
704 "created_utc",
705 "dependency_lock_sha256",
706 "fluidfft",
707 "fluidsim",
708 "python",
709 "restart",
710 }
711 validate_keys(
712 provenance,
713 allowed=provenance_keys,
714 required={
715 "config_id",
716 "created_utc",
717 "dependency_lock_sha256",
718 "fluidfft",
719 "fluidsim",
720 "python",
721 },
722 context="provenance",
723 )
724 for key in (
725 "config_id",
726 "created_utc",
727 "dependency_lock_sha256",
728 "fluidfft",
729 "fluidsim",
730 "python",
731 ):
732 require_text(provenance[key], name=f"provenance.{key}", maximum=256)
733 if provenance["fluidsim"] != FLUIDSIM_VERSION:
734 _record(errors, "fluidsim_version", f"pin fluidsim to {FLUIDSIM_VERSION}")
735 if provenance["fluidfft"] != FLUIDFFT_VERSION:
736 _record(errors, "fluidfft_version", f"pin fluidfft to {FLUIDFFT_VERSION}")
737 if "restart" in provenance:
738 restart = require_mapping(provenance["restart"], context="provenance.restart")
739 validate_keys(
740 restart,
741 allowed={"path", "sha256", "source_fluidsim"},
742 required={"path", "sha256", "source_fluidsim"},
743 context="provenance.restart",
744 )
745 safe_relative_path(
746 restart["path"],
747 name="provenance.restart.path",
748 suffixes={".nc", ".h5", ".hdf5", ".json"},
749 allow_nested=True,
750 )
751 if not re.fullmatch(r"[0-9a-f]{64}", str(restart["sha256"])):
752 _record(errors, "restart_digest", "restart sha256 must be 64 lowercase hex")
754 return {
755 "errors": errors,
756 "numerical_convergence_established": False,
757 "ok": not errors,
758 "physical_validity_established": False,
759 "recognized_solver_keys": sorted(SOLVER_IMPORTS),
760 "review_date": REVIEW_DATE,
761 "schema_version": "1.1",
762 "solver": solver,
763 "warnings": warnings,
764 }
767def example_config() -> dict[str, Any]:
768 """Return a bounded pilot configuration used by docs and tests."""
770 return {
771 "schema_version": "1.1",
772 "solver": "ns2d",
773 "parameters": {
774 "nu_2": 0.001,
775 "nu_4": 0.0,
776 "nu_8": 0.0,
777 "nu_m4": 0.0,
778 "oper": {
779 "nx": 32,
780 "ny": 32,
781 "Lx": 6.283185307179586,
782 "Ly": 6.283185307179586,
783 "coef_dealiasing": 0.6666666666666666,
784 "truncation_shape": "cubic",
785 "type_fft": "default",
786 },
787 "time_stepping": {
788 "USE_CFL": True,
789 "USE_T_END": True,
790 "cfl_coef": 0.5,
791 "deltat0": 0.001,
792 "deltat_max": 0.01,
793 "max_elapsed": "00:05:00",
794 "t_end": 0.1,
795 "type_time_scheme": "RK4",
796 },
797 "init_fields": {
798 "type": "noise",
799 "noise": {"length": 0.0, "velo_max": 0.01},
800 },
801 "forcing": {
802 "enable": False,
803 "type": "",
804 "forcing_rate": 1.0,
805 "nkmin_forcing": 4,
806 "nkmax_forcing": 5,
807 "key_forced": None,
808 },
809 "output": {
810 "HAS_TO_SAVE": True,
811 "ONLINE_PLOT_OK": False,
812 "sub_directory": "bounded-pilot",
813 "periods_save": {
814 "phys_fields": 0.05,
815 "spatial_means": 0.01,
816 "spectra": 0.05,
817 "spect_energy_budg": 0.0,
818 "increments": 0.0,
819 "spectra_multidim": 0.0,
820 "temporal_spectra": 0.0,
821 "spatiotemporal_spectra": 0.0,
822 },
823 "periods_print": {"print_stdout": 0.01},
824 "periods_plot": {"phys_fields": 0.0},
825 "phys_fields": {"field_to_plot": "rot", "file_with_it": False},
826 },
827 },
828 "scientific": {
829 "units_or_nondimensionalization": (
830 "All quantities are nondimensionalized by stated L0 and U0 scales."
831 ),
832 "boundary_conditions": "Periodic in x and y.",
833 "initial_conditions": (
834 "Seeded low-amplitude noise; document spectrum and amplitude."
835 ),
836 "forcing": "No forcing in this pilot.",
837 "resolution_and_dealiasing": (
838 "32x32 pilot with cubic 2/3 truncation; not a production resolution."
839 ),
840 "timestep_and_cfl": (
841 "Adaptive CFL with cfl_coef=0.5 and deltat_max=0.01."
842 ),
843 "conservation_and_budgets": [
844 "Track energy and enstrophy balances including dissipation.",
845 "Check divergence and spectral-tail contamination.",
846 ],
847 "convergence_and_refinement": (
848 "Repeat at finer grids and smaller CFL/deltat_max before inference."
849 ),
850 "acceptance_criteria": [
851 "No unexplained budget residual trend.",
852 "Reported observables stable under planned refinement.",
853 ],
854 },
855 "resources": {
856 "cpu_cores": 1,
857 "mpi_ranks": 1,
858 "threads_per_rank": 1,
859 "ram_gib": 2.0,
860 "disk_gib": 1.0,
861 "wall_time_minutes": 5,
862 "max_output_files": 32,
863 },
864 "execution": {
865 "mode": "serial",
866 "output_root": "fluidsim-runs",
867 "random_seed": 12345,
868 "script_name": "run_ns2d.py",
869 },
870 "provenance": {
871 "config_id": "replace-with-study-config-id",
872 "created_utc": "2026-10-01T00:00:00Z",
873 "dependency_lock_sha256": "replace-with-uv-lock-sha256",
874 "fluidfft": FLUIDFFT_VERSION,
875 "fluidsim": FLUIDSIM_VERSION,
876 "python": "3.11",
877 },
878 }
881def flatten_parameters(
882 parameters: Mapping[str, Any], prefix: tuple[str, ...] = ()
883) -> list[tuple[tuple[str, ...], Any]]:
884 """Flatten validated parameter mappings into deterministic assignments."""
886 flattened: list[tuple[tuple[str, ...], Any]] = []
887 for key in sorted(parameters):
888 value = parameters[key]
889 path = (*prefix, key)
890 if isinstance(value, Mapping):
891 flattened.extend(flatten_parameters(value, path))
892 else:
893 flattened.append((path, value))
894 return flattened
897def normalized_copy(document: Any) -> dict[str, Any]:
898 """Return an isolated JSON-compatible copy after successful validation."""
900 report = validate_config(document)
901 if not report["ok"]:
902 messages = "; ".join(item["message"] for item in report["errors"][:5])
903 raise ToolError(f"configuration is not valid: {messages}")
904 return deepcopy(dict(document))