6@brief A comprehensive conductor script for the PICurv simulation platform.
8This script acts as the central user interface for running simulations,
9managing configurations, and orchestrating the entire end-to-end workflow.
10It translates user-friendly YAML files into C-solver compatible control files,
11supports full multi-block configurations, and provides live log streaming.
12It features intelligent, content-based config file discovery and robustly
13manages data I/O paths for the post-processor. It also supports Slurm job
14generation/submission and parameter sweeps via job arrays.
34from datetime
import datetime
40from pathlib
import Path
43 from .storage
import (
46 restore_cold_study_members,
48 read_artifact_identity,
49 STORAGE_LOCK_FILENAME,
50 STORAGE_STATE_FILENAME,
51 require_storage_payload_local,
53 storage_state_summary,
59 _package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
60 if _package_parent
not in sys.path:
61 sys.path.insert(0, _package_parent)
62 _storage_module = importlib.import_module(
"picurv_cli.storage")
63 StorageError = _storage_module.StorageError
64 cold_study_members = _storage_module.cold_study_members
65 restore_cold_study_members = _storage_module.restore_cold_study_members
66 is_artifact_cold = _storage_module.is_artifact_cold
67 read_artifact_identity = _storage_module.read_artifact_identity
68 STORAGE_LOCK_FILENAME = _storage_module.STORAGE_LOCK_FILENAME
69 STORAGE_STATE_FILENAME = _storage_module.STORAGE_STATE_FILENAME
70 require_storage_payload_local = _storage_module.require_storage_payload_local
71 runtime_stage_lock = _storage_module.runtime_stage_lock
72 storage_state_summary = _storage_module.storage_state_summary
75_MATPLOTLIB_PYPLOT =
None
80 @brief Module-like proxy that preserves `picurv.np` without eager import.
85 @brief Resolve a NumPy attribute on first use.
86 @param[in] name NumPy attribute name.
87 @return Requested NumPy attribute.
97 @brief Remove site-package paths for a different Python major/minor version.
98 @param[in] paths Candidate sys.path entries.
99 @return Filtered path list.
101 current = (sys.version_info[0], sys.version_info[1])
102 pattern = re.compile(
r"python(?:-)?(\d+)\.(\d+)", re.IGNORECASE)
106 match = pattern.search(text)
108 path_version = (int(match.group(1)), int(match.group(2)))
109 if path_version != current
and (
"site-packages" in text
or "dist-packages" in text):
111 filtered.append(path)
117 @brief Remove a failed/partial import package tree from sys.modules.
118 @param[in] package_name Top-level package name.
120 prefix = package_name +
"."
121 for module_name
in list(sys.modules):
122 if module_name == package_name
or module_name.startswith(prefix):
123 sys.modules.pop(module_name,
None)
128 @brief Import NumPy only for commands that need numeric reductions.
129 @return Imported NumPy module.
132 if _NUMPY_MODULE
is not None:
136 except Exception
as exc:
138 original_path =
list(sys.path)
143 except Exception
as retry_exc:
145 "NumPy is required for this operation, but no compatible NumPy "
146 "could be imported for this Python interpreter. PICurv ignored "
147 "site-packages paths for other Python versions and retried. "
148 f
"First error: {first_error}. Retry error: {retry_exc}"
151 sys.path = original_path
152 _NUMPY_MODULE = numpy
158 @brief Import matplotlib.pyplot lazily for study plot generation.
159 @return matplotlib.pyplot when available, otherwise None.
161 global _MATPLOTLIB_PYPLOT
162 if _MATPLOTLIB_PYPLOT
is not None:
163 return _MATPLOTLIB_PYPLOT
164 original_path =
list(sys.path)
166 import matplotlib.pyplot
as pyplot
171 import matplotlib.pyplot
as pyplot
175 sys.path = original_path
176 _MATPLOTLIB_PYPLOT = pyplot
177 return _MATPLOTLIB_PYPLOT
182PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))
183PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)
184INVOKED_SCRIPT_DIR = os.environ.get(
185 "_PICURV_INVOKED_SCRIPT_DIR",
188SCRIPT_PATH = os.environ.get(
189 "_PICURV_SCRIPT_PATH",
192PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)
193GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT,
"generators")
194if os.path.basename(SCRIPT_PATH) ==
"bin":
195 DEFAULT_BIN_DIR = SCRIPT_PATH
197 DEFAULT_BIN_DIR = os.path.join(PROJECT_ROOT,
"bin")
199VERSION_FILE = os.path.join(PACKAGE_PROJECT_ROOT,
"VERSION")
204 @brief Read the single release version shared by every PICurv executable.
205 @return Release version from VERSION, or a safe development fallback.
207 configured = os.environ.get(
"PICURV_RELEASE_VERSION")
209 return configured.strip()
211 with open(VERSION_FILE,
"r", encoding=
"utf-8")
as stream:
212 value = stream.read().strip()
215 return value
or "0.0.0"
220 @brief Resolve reproducible release, commit, and dirty-tree build identity.
221 @param[in] release_version Release version read from VERSION.
222 @return Mapping suitable for manifests and user-facing status output.
225 "release_version": release_version,
226 "version": release_version,
228 "git_short_commit":
None,
230 "build_id": release_version,
233 commit_result = subprocess.run(
234 [
"git",
"rev-parse",
"HEAD"], cwd=PACKAGE_PROJECT_ROOT,
235 text=
True, capture_output=
True, check=
False,
237 dirty_result = subprocess.run(
238 [
"git",
"status",
"--porcelain",
"--untracked-files=no"],
239 cwd=PACKAGE_PROJECT_ROOT, text=
True, capture_output=
True, check=
False,
241 if commit_result.returncode == 0:
242 commit = commit_result.stdout.strip()
243 identity[
"git_commit"] = commit
244 identity[
"git_short_commit"] = commit[:12]
245 if dirty_result.returncode == 0:
246 identity[
"dirty"] = bool(dirty_result.stdout.strip())
251 identity[
"dev_distance"] =
None
252 identity[
"released"] =
False
254 tag_result = subprocess.run(
255 [
"git",
"describe",
"--tags",
"--match", f
"v{release_version}",
"--long"],
256 cwd=PACKAGE_PROJECT_ROOT, text=
True, capture_output=
True, check=
False,
258 if tag_result.returncode == 0:
259 parts = tag_result.stdout.strip().rsplit(
"-", 2)
260 if len(parts) == 3
and parts[1].isdigit():
261 identity[
"dev_distance"] = int(parts[1])
264 count_result = subprocess.run(
265 [
"git",
"rev-list",
"--count",
"HEAD"], cwd=PACKAGE_PROJECT_ROOT,
266 text=
True, capture_output=
True, check=
False,
268 if count_result.returncode == 0
and count_result.stdout.strip().isdigit():
269 identity[
"dev_distance"] = int(count_result.stdout.strip())
272 identity[
"released"] = identity[
"dev_distance"] == 0
and not identity[
"dirty"]
273 if identity[
"git_short_commit"]:
274 development =
"" if identity[
"dev_distance"]
in (0,
None)
else f
".dev{identity['dev_distance']}"
275 suffix = f
"{development}+g{identity['git_short_commit']}"
276 if identity[
"dirty"]:
278 identity[
"build_id"] = release_version + suffix
279 identity[
"version"] = identity[
"build_id"]
285PICURV_VERSION = PICURV_BUILD[
"version"]
286CASE_ORIGIN_METADATA_FILENAME =
".picurv-origin.json"
287WORKSPACE_CONFIG_FILENAME =
".picurv-workspace.yml"
288WORKSPACE_SCHEMA_VERSION = 1
289RUN_MANIFEST_SCHEMA_VERSION = 3
290ASSET_MANIFEST_SCHEMA_VERSION = 1
291ASSET_LOCK_SCHEMA_VERSION = 1
292RUNTIME_EXECUTION_CONFIG_FILENAME =
".picurv-execution.yml"
293LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME =
".picurv-local.yml"
294RUNTIME_EXECUTION_EXAMPLE_FILENAME =
"execution.example.yml"
295RUNTIME_EXECUTION_CONFIG_FILENAMES = (
296 RUNTIME_EXECUTION_CONFIG_FILENAME,
297 LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME,
300DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE =
"""# Optional shared runtime launcher overrides.
301# This file is safe to leave unchanged on ordinary local machines.
302# Edit it only when your site needs custom MPI launcher tokens.
305# - local/login-node runs: local_execution -> default_execution -> built-in mpiexec
306# - generated cluster jobs: cluster.yml.execution -> cluster_execution -> default_execution -> built-in srun
321CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT =
"my_project_account"
322CLUSTER_TEMPLATE_PLACEHOLDER_MAIL =
"user@example.edu"
324DEFAULT_WALLTIME_GUARD_POLICY = {
329 "estimator_alpha": 0.35,
331WALLTIME_GUARD_ENV_JOB_START_EPOCH =
"PICURV_JOB_START_EPOCH"
332WALLTIME_GUARD_ENV_LIMIT_SECONDS =
"PICURV_WALLTIME_LIMIT_SECONDS"
333POST_RESUME_STATE_FILENAME =
"post.resume.json"
334POST_LOCK_FILENAME =
"post.lock"
335POST_LOCK_METADATA_FILENAME =
"post.lock.json"
336POST_LOCK_WRAPPER_FILENAME =
"post_lock_wrapper.py"
337POST_RESUME_SCHEMA_VERSION = 1
338POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {
"startTime",
"endTime"}
339CHECKPOINT_FORMAT =
"picurv-checkpoint"
340CHECKPOINT_VERSION = 1
341CHECKPOINT_STEP_WIDTH = 12
342CHECKPOINT_REQUIRED_EULERIAN_FIELDS = {
"Ucat",
"Ucont",
"Ucont_rm1",
"P",
"Nvert"}
344WORKSPACE_DIRECTORY_LAYOUT = (
348 "config/initial_conditions",
349 "config/inlet_profiles",
352 "inputs/initial_conditions",
353 "inputs/inlet_profiles",
354 "inputs/reference_fields",
357 "assets/objects/grids",
358 "assets/objects/initial_conditions",
359 "assets/objects/inlet_profiles",
365RUN_DIRECTORY_LAYOUT = (
368 "config/post-recipes",
371 "inputs/initial_condition",
372 "inputs/inlet_profiles",
375 "output/checkpoints",
377 "output/analysis/metrics",
378 "output/analysis/statistics",
379 "output/analysis/spectra",
380 "output/analysis/plots",
381 "output/visualization",
386CANONICAL_RUN_PATHS = {
389 "restart":
"inputs/restart",
391 "checkpoints":
"output/checkpoints",
392 "analysis":
"output/analysis",
393 "metrics":
"output/analysis/metrics",
394 "statistics":
"output/analysis/statistics",
395 "spectra":
"output/analysis/spectra",
396 "plots":
"output/analysis/plots",
397 "visualization":
"output/visualization",
399 "scheduler":
"scheduler",
404INITIAL_CONDITION_SPECTRUM_RELPATH = os.path.join(
405 CANONICAL_RUN_PATHS[
"spectra"],
"initial_condition_spectrum.csv"
408_WORKSPACE_ARTIFACT_ROOT_VALUES = {
"runs",
"studies"}
409_ASSET_SOURCE_REFERENCE_KEYS = {
410 "source_file",
"path",
"config_file",
"field_file",
"grid_file",
"source_case",
"script"
412_PYTHON_INITIAL_CONDITION_PROVIDERS = {
"ic_gen",
"spectral_random_velocity"}
413_FILE_BACKED_GRID_VALUES = {
"file",
"grid_gen"}
414_WORKSPACE_MANAGED_PATHS = {
"assets",
"inputs",
"runs",
"studies"}
415_VENDORABLE_CONFIG_REFERENCE_KEYS = {
"config_file",
"script"}
416_PLAIN_FILENAME_SENTINELS = {
"",
".",
".."}
417_VERSION_BUILD_ACTIONS = {
"install",
"activate"}
422 @brief Find a named file at or above an arbitrary filesystem anchor.
423 @param[in] start File or directory from which to search.
424 @param[in] filename Basename to locate.
425 @return Absolute path when found, otherwise None.
427 current = os.path.abspath(start
or os.getcwd())
428 if os.path.isfile(current):
429 current = os.path.dirname(current)
431 candidate = os.path.join(current, filename)
432 if os.path.isfile(candidate):
434 parent = os.path.dirname(current)
435 if parent == current:
442 @brief Locate the nearest initialized PICurv workspace for supplied anchors.
443 @param[in] anchors Candidate config, run, study, or current-working-directory paths.
444 @return Workspace root path when found, otherwise None.
446 for anchor
in anchors
or (os.getcwd(),):
451 return os.path.dirname(found)
457 @brief Load and validate the immutable workspace identity/configuration file.
458 @param[in] workspace_root Initialized workspace directory.
459 @return Parsed workspace configuration mapping.
461 path = os.path.join(os.path.abspath(workspace_root), WORKSPACE_CONFIG_FILENAME)
463 if payload.get(
"schema_version") != WORKSPACE_SCHEMA_VERSION:
465 f
"{path}: unsupported workspace schema_version "
466 f
"{payload.get('schema_version')!r}; expected {WORKSPACE_SCHEMA_VERSION}."
471 raise ValueError(
"\n".join(errors))
477 @brief Enforce an optional workspace PICurv version requirement.
478 @param[in] workspace_root Initialized workspace directory.
479 @return Current build identity after successful validation.
482 software = payload.get(
"software")
or {}
483 requirement = software.get(
"picurv")
if isinstance(software, dict)
else None
484 if requirement
in (
None,
""):
485 return dict(PICURV_BUILD)
487 from packaging.specifiers
import SpecifierSet
488 from packaging.version
import Version
489 requirement_text = str(requirement).strip()
490 if not any(token
in requirement_text
for token
in "<>=!~"):
491 requirement_text =
"==" + requirement_text
492 matches = Version(PICURV_RELEASE_VERSION)
in SpecifierSet(requirement_text)
493 except Exception
as exc:
495 f
"{WORKSPACE_CONFIG_FILENAME}: software.picurv={requirement!r} is not a valid "
496 "version or version range."
500 f
"Workspace requires PICurv {requirement!r}, but the active release is "
501 f
"{PICURV_RELEASE_VERSION} (build {PICURV_BUILD['build_id']}).\n"
502 f
"Activate a matching installation with 'picurv versions activate <version>' "
504 f
"Note: {PACKAGE_PROJECT_ROOT} is a single shared installation. Activating "
505 "re-points every workspace and every unpinned job that resolves executables "
506 "from it, so two workspaces pinned to different releases cannot both be "
507 "satisfied at once. Pin a case's executables with 'picurv init --pin-binaries' "
508 "when it must survive an activation."
510 return dict(PICURV_BUILD)
515 @brief Enforce an optional workspace policy demanding a clean, released build.
517 @details Exploratory work should stay frictionless, so this is opt-in: a workspace
518 running a campaign that will be published sets it once, and PICurv then
519 refuses to stage from a modified or untagged tree instead of recording the
520 compromise in a manifest nobody reads until later.
521 @param[in] workspace_root Initialized workspace directory, or None.
522 @return The resolved policy mapping, empty when none is configured.
523 @throws ValueError when the active build does not satisfy the configured policy.
525 if not workspace_root:
528 policy = payload.get(
"reproducibility")
or {}
529 if not isinstance(policy, dict)
or not policy:
531 if policy.get(
"require_clean_release"):
533 if PICURV_BUILD.get(
"dirty"):
534 problems.append(
"the source tree has uncommitted changes")
535 if PICURV_BUILD.get(
"dev_distance"):
537 f
"HEAD is {PICURV_BUILD['dev_distance']} commit(s) past the "
538 f
"v{PICURV_RELEASE_VERSION} release tag"
540 elif PICURV_BUILD.get(
"dev_distance")
is None:
541 problems.append(
"the release tag could not be resolved from this checkout")
544 f
"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.require_clean_release, "
545 f
"but this build is {PICURV_BUILD['build_id']}: " +
"; ".join(problems) +
".\n"
546 "Commit and tag the release, or clear the policy for exploratory work."
548 if policy.get(
"pin_executables"):
551 if identity.get(
"available")
and not identity.get(
"matches_source")
555 if not identity.get(
"available")
557 if stale
or unavailable:
560 detail.append(
"built from another revision: " +
", ".join(stale))
562 detail.append(
"no build identity available: " +
", ".join(unavailable))
564 f
"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.pin_executables, but "
565 "the executables do not match the active source (" +
"; ".join(detail) +
").\n"
566 "Run 'make all' so the run records the build that produced it."
573 @brief Resolve a user path against its workspace and reject implicit escapes.
574 @param[in] anchor_file Config file whose workspace owns the reference.
575 @param[in] candidate Workspace-relative path text.
576 @param[in] allow_external Whether an explicit import/reference operation permits an absolute path.
577 @return Absolute resolved path.
579 if not isinstance(candidate, str)
or not candidate.strip():
580 raise ValueError(
"Referenced path must be a non-empty string.")
581 text = os.path.expanduser(candidate.strip())
583 if not workspace_root:
584 return os.path.abspath(text
if os.path.isabs(text)
else os.path.join(os.path.dirname(os.path.abspath(anchor_file)), text))
585 if os.path.isabs(text):
587 return os.path.abspath(text)
589 f
"{anchor_file}: absolute path {candidate!r} is not allowed in workspace configuration; "
590 "import it with 'picurv inputs import' or use an explicit reference-mode import."
592 resolved = os.path.abspath(os.path.join(workspace_root, text))
593 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
595 f
"{anchor_file}: path {candidate!r} escapes the workspace; parent traversal is not allowed."
597 if resolved.endswith(
".reference.yml")
and os.path.isfile(resolved):
599 external = pointer.get(
"picurv_external_reference")
if isinstance(pointer, dict)
else None
600 if not isinstance(external, str)
or not os.path.isabs(external):
601 raise ValueError(f
"Invalid external-reference descriptor: {resolved}")
602 if not os.path.isfile(external):
603 raise ValueError(f
"Registered external input is unavailable: {external}")
610 @brief Materialize the uniform, cheap directory skeleton for one workspace.
611 @param[in] workspace_root Workspace root to initialize.
613 for relative
in WORKSPACE_DIRECTORY_LAYOUT:
614 os.makedirs(os.path.join(workspace_root, *relative.split(
"/")), exist_ok=
True)
619 @brief Create the workspace skeleton and its identity file at one root.
620 @param[in] workspace_root Workspace root to initialize.
621 @param[in] template_name Example template the workspace was created from.
622 @return Path to the written workspace configuration file.
624 workspace_root = os.path.abspath(workspace_root)
626 config_path = os.path.join(workspace_root, WORKSPACE_CONFIG_FILENAME)
628 "schema_version": WORKSPACE_SCHEMA_VERSION,
630 "id": os.path.basename(workspace_root),
631 "template": template_name,
632 "created_at": datetime.now().astimezone().isoformat(),
640 "studies":
"studies",
648 @brief Materialize the uniform, cheap directory skeleton for one run.
649 @param[in] run_dir Run root to initialize.
651 for relative
in RUN_DIRECTORY_LAYOUT:
652 os.makedirs(os.path.join(run_dir, *relative.split(
"/")), exist_ok=
True)
657RUN_DIRECTORY_ROOTS = frozenset(
658 relative.split(
"/")[0]
for relative
in RUN_DIRECTORY_LAYOUT
663RUN_ROOT_ALLOWED_FILES = frozenset({
"manifest.json"})
668 @brief Refuse a run whose root has grown a directory the layout does not define.
670 @details The run topology is a contract: `manifest.json` publishes it, the solver's
671 reserved-directory guard defends it, and storage classifies against it. A
672 directory beside `output/` breaks all three at once - it is routed by
673 nothing, classified as `unclassified`, and therefore archived forever and
674 pruned never. Catching it when the run is staged or resumed is the only
675 point where the answer is still "move it", rather than "it is already in
676 every archive of this run".
678 Unexpected *files* are reported and allowed. A stray note at a run root
679 costs nothing and refusing to resume a long campaign over one would be a
680 worse failure than the one being prevented.
681 @param[in] run_dir Run root to check.
682 @return Tuple of (errors, warnings) as human-readable message lists.
684 root = os.path.abspath(run_dir)
685 if not os.path.isdir(root):
687 allowed_files = set(RUN_ROOT_ALLOWED_FILES) | {
688 STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME,
690 errors, warnings = [], []
691 for name
in sorted(os.listdir(root)):
692 path = os.path.join(root, name)
693 if os.path.isdir(path)
and not os.path.islink(path):
694 if name
not in RUN_DIRECTORY_ROOTS:
696 f
"{run_dir}: '{name}/' is not part of the run layout. Scientific "
697 f
"output belongs under {CANONICAL_RUN_PATHS['checkpoints']}, "
698 f
"{CANONICAL_RUN_PATHS['analysis']}, or "
699 f
"{CANONICAL_RUN_PATHS['visualization']}; move or remove '{name}/' "
700 f
"before running. Run-owned roots are: "
701 +
", ".join(sorted(RUN_DIRECTORY_ROOTS)) +
"."
703 elif name
not in allowed_files:
705 f
"{run_dir}: unexpected file '{name}' at the run root. It is archived "
706 "as unclassified and never pruned."
708 return errors, warnings
713 @brief Apply `validate_run_directory_structure()` as a refusal at run time.
714 @param[in] run_dir Run root to check.
715 @return None. Exits non-zero when the run root carries a directory outside the layout.
718 for message
in warnings:
719 print(f
"[WARN] {message}", file=sys.stderr)
722 for message
in errors:
724 ERROR_CODE_CFG_INCONSISTENT_COMBO,
726 file_path=os.path.abspath(run_dir),
728 hint=
"Move the directory under output/ or out of the run, then retry.",
735 @brief Resolve the stable human-facing portion of a generated run identifier.
736 @param[in] case_cfg Parsed case configuration.
737 @param[in] case_path Source case YAML path.
738 @return Filesystem-safe case title, name, or source stem.
740 metadata = case_cfg.get(
"metadata")
or {}
742 case_cfg.get(
"title")
743 or (metadata.get(
"title")
if isinstance(metadata, dict)
else None)
744 or (metadata.get(
"name")
if isinstance(metadata, dict)
else None)
745 or Path(case_path).stem
747 label = re.sub(
r"[^A-Za-z0-9_.-]+",
"-", str(candidate).strip()).strip(
"-.")
748 return label
or "case"
753 @brief Build the generated run identity, disambiguating a same-second collision.
755 @details The identity is `<run label>_<timestamp>` at one-second resolution, so two
756 runs of the same case launched back to back - a script, or a smoke
757 sequence - would otherwise name the same directory. Creating a run never
758 writes into an existing one, so the identity is advanced instead of the
759 launch being refused for a clock artifact.
760 @param[in] runs_root Directory generated runs are created beneath.
761 @param[in] case_cfg Parsed case configuration supplying the run label.
762 @param[in] case_path Case path, for the label's fallback.
763 @return Run identity that does not currently exist under `runs_root`.
765 base = f
"{case_run_label(case_cfg, case_path)}_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
766 run_id, suffix = base, 1
767 while os.path.exists(os.path.join(runs_root, run_id)):
769 run_id = f
"{base}-{suffix}"
775 @brief Remove a generated run directory that never received any content.
777 @details Only a directory this invocation created, and which still holds no files,
778 is removed: an existing run, or one that already produced output, is never
779 touched by a staging failure.
780 @param[in] run_dir Run directory that was about to be staged.
781 @param[in] created Whether this invocation created the directory.
782 @return True when the directory was removed.
784 if not created
or not os.path.isdir(run_dir):
786 for _root, _dirs, files
in os.walk(run_dir):
789 shutil.rmtree(run_dir, ignore_errors=
True)
795 @brief Content digest of one file, or None when it cannot be read.
796 @param[in] path File to digest.
797 @return Hex digest, or None.
799 digest = hashlib.sha256()
801 with open(path,
"rb")
as stream:
802 for block
in iter(
lambda: stream.read(1024 * 1024), b
""):
806 return digest.hexdigest()
811 @brief Capture the exact software identity a run is about to execute with.
813 @details The release and commit say which source is checked out; they do not say
814 which bytes ran. Hashing the executables and the generators pins that, so
815 a queued job rebuilt out from under it is detectable afterwards rather
816 than merely suspected.
817 @return Software lock mapping.
821 "picurv_version": PICURV_RELEASE_VERSION,
822 "build_id": PICURV_BUILD.get(
"build_id"),
823 "git_commit": PICURV_BUILD.get(
"git_commit"),
824 "git_dirty": PICURV_BUILD.get(
"dirty"),
825 "captured_at": datetime.now().astimezone().isoformat(),
830 for name
in (
"simulator",
"postprocessor"):
833 lock[
"executables"][name] = {
836 "build_id": identity.get(
"build_id"),
837 "matches_source": identity.get(
"matches_source"),
839 conductor = os.path.join(PACKAGE_PROJECT_ROOT,
"picurv_cli",
"core.py")
840 lock[
"python_conductor_sha256"] =
_file_sha256(conductor)
841 if os.path.isdir(GENERATORS_PATH):
842 for entry
in sorted(os.listdir(GENERATORS_PATH)):
843 candidate = os.path.join(GENERATORS_PATH, entry)
844 if os.path.isfile(candidate):
851 @brief Best-effort record of the PETSc, MPI, and compiler the binaries were built on.
852 @return Mapping of what could be determined; absent keys mean it could not be read.
855 petsc_dir = os.environ.get(
"PETSC_DIR")
857 identity[
"petsc_dir"] = petsc_dir
858 version_header = os.path.join(petsc_dir,
"include",
"petscversion.h")
860 with open(version_header,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
864 r"#define\s+PETSC_VERSION_(MAJOR|MINOR|SUBMINOR)\s+(\d+)", line
867 numbers[match.group(1)] = match.group(2)
868 if len(numbers) == 3:
869 identity[
"petsc_version"] = (
870 f
"{numbers['MAJOR']}.{numbers['MINOR']}.{numbers['SUBMINOR']}"
874 if os.environ.get(
"PETSC_ARCH"):
875 identity[
"petsc_arch"] = os.environ[
"PETSC_ARCH"]
876 for tool, key
in ((
"mpiexec",
"mpi"), (
"mpicc",
"compiler")):
877 executable = shutil.which(tool)
881 result = subprocess.run(
882 [executable,
"--version"], text=
True, capture_output=
True, timeout=20, check=
False
884 except (OSError, subprocess.SubprocessError):
886 first = (result.stdout
or result.stderr
or "").strip().splitlines()
888 identity[key] = first[0][:200]
894 @brief Write the run's software lock beside its asset lock.
895 @param[in] run_dir Run directory receiving the lock.
896 @return Path to the written lock.
898 path = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"inputs"],
"software.lock.json")
899 os.makedirs(os.path.dirname(path), exist_ok=
True)
906 @brief Return the canonical workspace-owned root for runs or studies.
907 @param[in] workspace_root Initialized workspace, or None for standalone mode.
908 @param[in] kind Either runs or studies.
909 @return Absolute artifact root.
911 if kind
not in _WORKSPACE_ARTIFACT_ROOT_VALUES:
912 raise ValueError(f
"Unsupported workspace artifact kind: {kind}")
914 return os.path.join(os.path.abspath(workspace_root), kind)
915 return os.path.abspath(kind)
920 @brief Return a portable workspace-relative path when possible.
921 @param[in] path Path to normalize.
922 @param[in] workspace_root Optional initialized workspace root.
923 @return Workspace-relative POSIX path, absolute path, or None.
927 absolute = os.path.abspath(path)
928 if workspace_root
and os.path.commonpath([absolute, workspace_root]) == os.path.abspath(workspace_root):
929 return os.path.relpath(absolute, workspace_root).replace(os.sep,
"/")
934 continuation: bool =
False) -> dict:
936 @brief Snapshot editable YAML inputs without erasing prior continuation state.
937 @param[in] run_dir Owning run directory.
938 @param[in] source_paths Role-to-source-path mapping.
939 @param[in] continuation Whether this is a continuation configuration revision.
940 @return Active snapshot metadata.
942 config_root = os.path.join(run_dir,
"config")
943 os.makedirs(config_root, exist_ok=
True)
945 destination_root = config_root
947 revision = datetime.now().strftime(
"%Y%m%d-%H%M%S-%f")
948 destination_root = os.path.join(config_root,
"history", revision)
949 os.makedirs(destination_root, exist_ok=
False)
951 for role, source
in source_paths.items():
954 source = os.path.abspath(source)
955 suffix = os.path.splitext(source)[1]
or ".yml"
956 name = f
"{role}{suffix}" if role !=
"cluster" else "cluster.yml"
957 destination = os.path.join(destination_root, name)
958 shutil.copy2(source, destination)
959 snapshots[role] = os.path.relpath(destination, run_dir).replace(os.sep,
"/")
962 "revision": revision
or "initial",
963 "updated_at": datetime.now().astimezone().isoformat(),
972 @brief Load the active immutable configuration revision for a run.
973 @param[in] run_dir Run directory.
974 @return Role-to-absolute-path mapping.
976 config_root = os.path.join(os.path.abspath(run_dir),
"config")
978 if isinstance(active, dict)
and isinstance(active.get(
"files"), dict):
980 role: os.path.join(run_dir, *relative.split(
"/"))
981 for role, relative
in active[
"files"].items()
982 if isinstance(relative, str)
985 role: os.path.join(config_root, f
"{role}.yml")
986 for role
in (
"case",
"solver",
"monitor",
"cluster")
987 if os.path.isfile(os.path.join(config_root, f
"{role}.yml"))
993 @brief Preserve generated control sidecars beside a continuation's YAML revision.
994 @param[in] run_dir Owning run.
995 @param[in] paths Generated control and sidecar paths.
996 @return Updated active configuration record.
998 config_root = os.path.join(os.path.abspath(run_dir),
"config")
999 active_path = os.path.join(config_root,
"active.json")
1000 active =
_read_json_if_exists(active_path)
or {
"schema_version": 1,
"revision":
"initial",
"files": {}}
1001 revision = active.get(
"revision",
"initial")
1002 target_root = config_root
if revision ==
"initial" else os.path.join(config_root,
"history", revision)
1003 os.makedirs(target_root, exist_ok=
True)
1004 files = active.setdefault(
"files", {})
1005 for source
in paths:
1006 if not source
or not os.path.isfile(source):
1008 source = os.path.abspath(source)
1009 destination = os.path.join(target_root, os.path.basename(source))
1010 if source != destination:
1011 shutil.copy2(source, destination)
1012 relative = os.path.relpath(destination, run_dir).replace(os.sep,
"/")
1013 if destination.endswith(
".control"):
1014 files[
"control"] = relative
1016 files.setdefault(
"generated", []).append(relative)
1017 active[
"updated_at"] = datetime.now().astimezone().isoformat()
1024STORAGE_OFFLOADED_STATES = frozenset({
"COLD",
"PARTIAL"})
1027RUN_COMPONENT_STATES = (
1028 "not_requested",
"planned",
"running",
"partial",
"complete",
"failed",
"offloaded",
1032RUN_COMPONENT_LAYOUT = (
1033 (
"configuration",
"config",
"essential"),
1034 (
"inputs",
"inputs",
"essential"),
1035 (
"checkpoints",
"output/checkpoints",
"policy"),
1036 (
"analysis",
"output/analysis",
"derived"),
1037 (
"field_statistics",
"output/analysis/statistics",
"derived"),
1038 (
"spectra",
"output/analysis/spectra",
"derived"),
1039 (
"visualization",
"output/visualization",
"derived"),
1040 (
"logs",
"logs",
"essential"),
1041 (
"scheduler",
"scheduler",
"essential"),
1047 @brief Report each run component's home, retention class, and lifecycle state.
1049 @details The skeleton is created whole, so an empty directory says nothing about
1050 whether its contents were requested. This is where that question is
1051 answered, and it is answered for every component whether or not it ran.
1052 @param[in] run_dir Run directory being described.
1053 @param[in] stages_requested Mapping of requested stage names to booleans.
1054 @return Component name to `{path, retention, state}` mapping.
1056 marker = storage_state_summary(run_dir)
if os.path.isdir(run_dir)
else {
"state":
"LOCAL"}
1057 offloaded = marker.get(
"state")
in STORAGE_OFFLOADED_STATES
1058 solve_requested = bool(stages_requested.get(
"solve"))
1059 post_requested = bool(stages_requested.get(
"post_process"))
1061 "configuration":
True,
1065 "checkpoints": solve_requested,
1066 "analysis": post_requested,
1067 "field_statistics": post_requested,
1068 "spectra": post_requested,
1069 "visualization": post_requested,
1072 for name, relative, retention
in RUN_COMPONENT_LAYOUT:
1073 path = os.path.join(run_dir, *relative.split(
"/"))
1075 if os.path.isdir(path):
1076 populated = any(files
for _root, _dirs, files
in os.walk(path))
1078 state =
"offloaded" if offloaded
and retention !=
"essential" else "complete"
1079 elif not requested.get(name,
False):
1080 state =
"not_requested"
1085 components[name] = {
"path": relative,
"retention": retention,
"state": state}
1090 statistics_state=
None, requested_source=
None) -> dict:
1092 @brief Record which run and which checkpoint a branched run was started from.
1094 @details Without this a branch is indistinguishable from a fresh run: the copied
1095 bundle under `inputs/restart/` carries the parent's geometry and software
1096 identity but never says which run produced it, so the trajectory a result
1097 belongs to cannot be reconstructed after the fact.
1099 The parent's own manifest is asked for its identity rather than its
1100 directory name, so a parent that was renamed or restored under another
1101 name is still named correctly here.
1102 @param[in] parent_run_dir Absolute path to the run being branched from.
1103 @param[in] checkpoint_step Checkpoint step the branch starts from.
1104 @param[in] workspace_root Optional workspace root, to record a portable path.
1105 @param[in] statistics_state Resolved field-statistics decision for the branch.
1106 @param[in] requested_source What the user asked for, e.g. "latest" or a path.
1107 @return JSON-serializable lineage record.
1109 parent_identity = read_artifact_identity(parent_run_dir)
1111 "relationship":
"branch",
1112 "parent_run_id": parent_identity[
"run_id"],
1113 "parent_study_id": parent_identity[
"study_id"]
if parent_identity[
"case_id"]
else None,
1114 "parent_case_id": parent_identity[
"case_id"],
1115 "parent_identity_source": parent_identity[
"identity_source"],
1117 "checkpoint_step": int(checkpoint_step),
1118 "statistics_state": statistics_state,
1119 "requested_source": requested_source,
1120 "recorded_at": datetime.now().astimezone().isoformat(),
1125 launch_mode: str =
"local", num_procs: int = 1,
1126 post_num_procs: int = 1, stages_requested=
None,
1127 stages_completed=
None, inputs=
None, asset_lock=
None,
1128 submission=
None, lineage=
None, artifact_type: str =
"run",
1129 study_id=
None, case_id=
None) -> dict:
1131 @brief Build the authoritative run identity, topology, and lifecycle manifest.
1132 @param[in] run_dir Owning run directory.
1133 @param[in] run_id Stable run identity.
1134 @param[in] workspace_root Optional initialized workspace root.
1135 @param[in] launch_mode Local or scheduler-backed launch mode.
1136 @param[in] num_procs Effective solver process count.
1137 @param[in] post_num_procs Effective post-process count.
1138 @param[in] stages_requested Requested stage mapping.
1139 @param[in] stages_completed Completed or submitted stage names.
1140 @param[in] inputs Active configuration identities.
1141 @param[in] asset_lock Resolved run asset lock.
1142 @param[in] submission Scheduler submission metadata.
1143 @param[in] lineage Where this run's initial state came from; see `build_run_lineage()`.
1144 @param[in] artifact_type "run" for a standalone run, "study-case" for a study member.
1145 @param[in] study_id Owning study identity, for a study member.
1146 @param[in] case_id Member identity within its study.
1147 @return JSON-serializable run manifest.
1150 created_at = existing.get(
"created_at")
or datetime.now().astimezone().isoformat()
1152 "schema_version": RUN_MANIFEST_SCHEMA_VERSION,
1153 "artifact_type": artifact_type,
1155 "study_id": study_id,
1157 "created_at": created_at,
1158 "updated_at": datetime.now().astimezone().isoformat(),
1161 if workspace_root
else None
1163 "software": dict(PICURV_BUILD),
1168 "launch_mode": launch_mode,
1169 "num_procs": num_procs,
1170 "solver_num_procs": num_procs,
1171 "post_num_procs": post_num_procs,
1172 "stages_requested": stages_requested
or {},
1173 "stages_completed_or_submitted": stages_completed
or [],
1174 "inputs": inputs
or {},
1175 "paths": dict(CANONICAL_RUN_PATHS),
1180 "assets": (asset_lock
or {}).get(
"assets", {}),
1181 "runtime_providers": (asset_lock
or {}).get(
"runtime_providers", {}),
1186 "lineage": lineage
or existing.get(
"lineage")
or {
"relationship":
"root"},
1187 "submission": submission
or {},
1194 @brief Resolve a run/output root or an exact checkpoint bundle.
1195 @param[in] source_dir Run/output root or exact bundle path.
1196 @param[in] step Checkpoint step to resolve.
1197 @return Absolute path to the checkpoint bundle.
1199 source_dir = os.path.abspath(source_dir)
1200 if os.path.isfile(os.path.join(source_dir,
"checkpoint.meta")):
1202 return os.path.join(source_dir,
"checkpoints", f
"step_{step:0{CHECKPOINT_STEP_WIDTH}d}")
1207 @brief Parse the deliberately small PETSc-options checkpoint manifest.
1208 @param[in] metadata_path Path to checkpoint.meta.
1209 @return Mapping of option names to scalar text values.
1212 with open(metadata_path,
"r", encoding=
"utf-8")
as stream:
1213 for line_number, raw_line
in enumerate(stream, start=1):
1214 tokens = shlex.split(raw_line, comments=
True, posix=
True)
1217 if len(tokens) != 2
or not tokens[0].startswith(
"-"):
1219 f
"Invalid checkpoint metadata line {line_number} in {metadata_path}."
1223 raise ValueError(f
"Duplicate checkpoint metadata key '-{key}' in {metadata_path}.")
1224 options[key] = tokens[1]
1230 @brief Validate one committed bundle using the same manifest contract as C.
1231 @param[in] source_dir Run/output root or exact bundle path.
1232 @param[in] step Expected checkpoint step.
1233 @param[in] require_particles Whether particle restart payloads are required.
1234 @return Validated bundle path, metadata, and payload inventory.
1237 metadata_path = os.path.join(bundle,
"checkpoint.meta")
1238 commit_path = os.path.join(bundle,
"COMMITTED")
1239 if not os.path.isfile(metadata_path)
or not os.path.isfile(commit_path):
1240 raise ValueError(f
"No committed checkpoint for step {step}: {bundle}")
1242 with open(commit_path,
"r", encoding=
"ascii")
as stream:
1243 expected_digest = stream.read().strip()
1244 if not re.fullmatch(
r"[0-9a-fA-F]{64}", expected_digest):
1245 raise ValueError(f
"Invalid checkpoint commit marker: {commit_path}")
1246 with open(metadata_path,
"rb")
as stream:
1247 actual_digest = hashlib.sha256(stream.read()).hexdigest()
1248 if actual_digest.lower() != expected_digest.lower():
1249 raise ValueError(f
"Checkpoint metadata hash mismatch: {bundle}")
1253 saved_version = int(options.get(
"checkpoint_version",
"-1"))
1254 saved_step = int(options.get(
"checkpoint_step",
"-1"))
1255 payload_count = int(options.get(
"checkpoint_payload_count",
"-1"))
1256 particle_count = int(options.get(
"checkpoint_particle_count",
"-1"))
1257 block_count = int(options.get(
"checkpoint_block_count",
"-1"))
1258 float(options[
"checkpoint_time"])
1259 except (KeyError, TypeError, ValueError)
as exc:
1260 raise ValueError(f
"Checkpoint metadata is incomplete or malformed: {metadata_path}")
from exc
1261 if options.get(
"checkpoint_format") != CHECKPOINT_FORMAT
or saved_version != CHECKPOINT_VERSION:
1262 raise ValueError(f
"Unsupported checkpoint format/version: {metadata_path}")
1263 if saved_step != step:
1264 raise ValueError(f
"Checkpoint records step {saved_step}, expected {step}: {bundle}")
1265 if payload_count <= 0
or particle_count < 0
or block_count <= 0:
1266 raise ValueError(f
"Checkpoint inventory counts are invalid: {metadata_path}")
1269 for index
in range(payload_count):
1270 prefix = f
"checkpoint_payload_{index}_"
1271 relative_path = options.get(prefix +
"path")
1273 expected_bytes = int(options[prefix +
"bytes"])
1274 except (KeyError, TypeError, ValueError)
as exc:
1275 raise ValueError(f
"Checkpoint payload {index} has invalid metadata: {metadata_path}")
from exc
1276 if not relative_path
or os.path.isabs(relative_path)
or ".." in relative_path.split(
"/"):
1277 raise ValueError(f
"Checkpoint payload {index} has an unsafe path: {relative_path!r}")
1278 payload_path = os.path.join(bundle, *relative_path.split(
"/"))
1279 if not os.path.isfile(payload_path)
or os.path.getsize(payload_path) != expected_bytes:
1280 raise ValueError(f
"Checkpoint payload is missing or truncated: {payload_path}")
1282 "path": relative_path,
1283 "kind": options.get(prefix +
"kind"),
1284 "field": options.get(prefix +
"field"),
1285 "block": options.get(prefix +
"block"),
1288 for block
in range(block_count):
1290 item[
"field"]
for item
in payloads
1291 if item[
"kind"] ==
"eulerian" and item[
"block"] == str(block)
1293 missing = CHECKPOINT_REQUIRED_EULERIAN_FIELDS - fields
1296 f
"Checkpoint block {block} is missing required Eulerian field(s): "
1297 f
"{', '.join(sorted(missing))}."
1299 particle_payloads = [item
for item
in payloads
if item[
"kind"] ==
"particle"]
1300 has_particles = options.get(
"checkpoint_particles",
"false").lower() ==
"true"
1301 if (
not has_particles
and particle_count != 0)
or (has_particles
and not particle_payloads):
1302 raise ValueError(f
"Checkpoint particle inventory is inconsistent: {metadata_path}")
1303 if require_particles
and not has_particles:
1304 raise ValueError(f
"Checkpoint step {step} does not contain particle state: {bundle}")
1308 "metadata": options,
1309 "payloads": payloads,
1310 "has_particles": has_particles,
1311 "particle_count": particle_count,
1317 @brief Return only fully validated, committed checkpoint steps.
1318 @param[in] source_dir Run/output root containing checkpoints.
1319 @param[in] require_particles Whether particle restart payloads are required.
1320 @return Set of valid committed step numbers.
1322 checkpoints_dir = os.path.join(os.path.abspath(source_dir),
"checkpoints")
1323 if not os.path.isdir(checkpoints_dir):
1325 pattern = re.compile(rf
"^step_(\d{{{CHECKPOINT_STEP_WIDTH}}})$")
1327 for name
in os.listdir(checkpoints_dir):
1328 match = pattern.fullmatch(name)
1331 step = int(match.group(1))
1342 @brief Parse a Slurm time-limit string into total seconds.
1343 @param[in] time_text Argument passed to `parse_slurm_time_limit_to_seconds()`.
1344 @return Value returned by `parse_slurm_time_limit_to_seconds()`.
1346 text = str(time_text).strip()
1348 raise ValueError(
"time limit cannot be empty")
1353 day_text, clock_text = text.split(
"-", 1)
1354 if not day_text.isdigit():
1355 raise ValueError(f
"invalid day field '{day_text}'")
1356 days = int(day_text)
1358 raise ValueError(
"missing time portion after day field")
1360 parts = clock_text.split(
":")
1362 raise ValueError(f
"unsupported time format '{time_text}'")
1363 if any(part ==
"" for part
in parts):
1364 raise ValueError(f
"malformed time field '{time_text}'")
1365 if any(
not part.isdigit()
for part
in parts):
1366 raise ValueError(f
"non-numeric time field '{time_text}'")
1368 nums = [int(part)
for part
in parts]
1371 hours, minutes, seconds = nums[0], 0, 0
1372 elif len(nums) == 2:
1373 hours, minutes = nums
1376 hours, minutes, seconds = nums
1379 hours, minutes, seconds = 0, nums[0], 0
1380 elif len(nums) == 2:
1382 minutes, seconds = nums
1384 hours, minutes, seconds = nums
1386 if minutes >= 60
or seconds >= 60:
1387 raise ValueError(f
"minutes and seconds must be < 60 in '{time_text}'")
1388 if days == 0
and len(nums) == 3
and hours < 0:
1389 raise ValueError(f
"hours must be non-negative in '{time_text}'")
1391 total_seconds = (((days * 24) + hours) * 60 + minutes) * 60 + seconds
1392 if total_seconds <= 0:
1393 raise ValueError(
"time limit must be positive")
1394 return total_seconds
1399 @brief Resolve the effective Slurm walltime-guard policy for generated solver jobs.
1400 @param[in] cluster_cfg Argument passed to `resolve_walltime_guard_policy()`.
1401 @return Value returned by `resolve_walltime_guard_policy()`.
1403 if not isinstance(cluster_cfg, dict):
1406 scheduler = cluster_cfg.get(
"scheduler", {})
or {}
1407 if str(scheduler.get(
"type",
"slurm")).lower() !=
"slurm":
1410 execution = cluster_cfg.get(
"execution", {})
or {}
1411 guard_cfg = execution.get(
"walltime_guard")
1412 if guard_cfg
is None:
1414 elif not isinstance(guard_cfg, dict):
1415 raise ValueError(
"execution.walltime_guard must be a mapping when provided")
1417 policy = copy.deepcopy(DEFAULT_WALLTIME_GUARD_POLICY)
1418 policy.update(guard_cfg)
1419 policy[
"enabled"] = bool(policy[
"enabled"])
1420 policy[
"warmup_steps"] = int(policy[
"warmup_steps"])
1421 policy[
"multiplier"] = float(policy[
"multiplier"])
1422 policy[
"min_seconds"] = float(policy[
"min_seconds"])
1423 policy[
"estimator_alpha"] = float(policy[
"estimator_alpha"])
1429 @brief Build shell-evaluated environment exports for the runtime walltime guard.
1430 @param[in] cluster_cfg Argument passed to `build_walltime_guard_exports()`.
1431 @return Value returned by `build_walltime_guard_exports()`.
1434 if not policy
or not policy.get(
"enabled",
False):
1438 WALLTIME_GUARD_ENV_JOB_START_EPOCH:
"$(date +%s)",
1439 WALLTIME_GUARD_ENV_LIMIT_SECONDS: str(walltime_limit_seconds),
1444 @brief Resolve solver/post executable path, preferring local sibling binaries.
1445 @param[in] executable_name Argument passed to `resolve_runtime_executable()`.
1446 @return Value returned by `resolve_runtime_executable()`.
1448 local_candidate = os.path.join(INVOKED_SCRIPT_DIR, executable_name)
1449 if os.path.isfile(local_candidate):
1450 return os.path.abspath(local_candidate)
1451 return os.path.join(DEFAULT_BIN_DIR, executable_name)
1455_BINARY_VERSION_PATTERN = re.compile(
1456 r"^(?P<name>\S+)\s+(?P<release>[^+\s]+)\+g(?P<commit>[0-9a-f]+)(?P<dirty>\.dirty)?\s*$"
1462 @brief Read the build identity a native executable was compiled with.
1464 @details The Makefile stamps the release, commit, and dirty state into the
1465 binaries, and they are written into every checkpoint manifest. Reading it
1466 back is what makes the run manifest's provenance a statement about the
1467 binary that ran rather than about whatever source happens to be checked
1468 out when the conductor is invoked.
1469 @param[in] executable_path Path to `simulator` or `postprocessor`.
1470 @return Identity mapping, or an `available: False` mapping when it cannot be read.
1472 if not os.path.isfile(executable_path)
or not os.access(executable_path, os.X_OK):
1473 return {
"available":
False,
"reason":
"not built",
"path": executable_path}
1475 result = subprocess.run(
1476 [executable_path,
"--version"], text=
True, capture_output=
True, timeout=30,
1478 except (OSError, subprocess.SubprocessError)
as exc:
1479 return {
"available":
False,
"reason": str(exc),
"path": executable_path}
1480 match = _BINARY_VERSION_PATTERN.match((result.stdout
or "").strip())
1484 return {
"available":
False,
"reason":
"no build identity reported",
1485 "path": executable_path}
1488 "path": executable_path,
1489 "release_version": match.group(
"release"),
1490 "git_commit": match.group(
"commit"),
1491 "dirty": bool(match.group(
"dirty")),
1493 f
"{match.group('release')}+g{match.group('commit')}"
1494 f
"{'.dirty' if match.group('dirty') else ''}"
1501 @brief Read the build identity of every native executable a run would launch.
1502 @return Mapping of executable name to its identity, each carrying `matches_source`.
1505 source_commit = str(PICURV_BUILD.get(
"git_commit")
or "")
1506 for name
in (
"simulator",
"postprocessor"):
1508 if identity.get(
"available"):
1510 identity[
"matches_source"] = bool(
1512 and source_commit.startswith(identity[
"git_commit"])
1513 and identity[
"dirty"] == bool(PICURV_BUILD.get(
"dirty"))
1515 identities[name] = identity
1521 @brief Report every reason the active build identity is not internally coherent.
1523 @details The conductor, the simulator, and the postprocessor are three artifacts
1524 that must agree before a run's provenance means anything. This states the
1525 disagreements rather than printing them, so `version status` can exit on
1526 them while ordinary staging only warns.
1527 @param[in] identities Mapping returned by `runtime_build_identities()`.
1528 @param[in] workspace_requirement Optional `software.picurv` constraint to check.
1529 @return Human-readable problem descriptions; empty when the build is coherent.
1532 for name, identity
in sorted(identities.items()):
1533 if not identity.get(
"available"):
1535 f
"{name}: no build identity available ({identity.get('reason', 'unknown')}); "
1536 "rebuild with 'make all'."
1538 elif not identity.get(
"matches_source"):
1540 f
"{name}: built from {identity['build_id']}, but the active source is "
1541 f
"{PICURV_BUILD['build_id']}; rebuild with 'make all'."
1543 if workspace_requirement
not in (
None,
""):
1545 from packaging.specifiers
import SpecifierSet
1546 from packaging.version
import Version
1547 requirement_text = str(workspace_requirement).strip()
1548 if not any(token
in requirement_text
for token
in "<>=!~"):
1549 requirement_text =
"==" + requirement_text
1550 satisfied = Version(PICURV_RELEASE_VERSION)
in SpecifierSet(requirement_text)
1553 f
"workspace: software.picurv={workspace_requirement!r} is not a valid "
1554 "version or version range."
1559 f
"workspace: requires PICurv {workspace_requirement!r}, but the active "
1560 f
"release is {PICURV_RELEASE_VERSION}."
1567 @brief Report native executables whose build identity is not the active source.
1568 @param[in] identities Mapping returned by `runtime_build_identities()`.
1569 @return Names of executables that disagree with the active source identity.
1571 stale = [name
for name, identity
in sorted(identities.items())
1572 if identity.get(
"available")
and not identity.get(
"matches_source")]
1575 f
"[WARN] {name} was built from {identities[name]['build_id']}, but the active "
1576 f
"source is {PICURV_BUILD['build_id']}. Checkpoints will record the binary's "
1577 "identity, not the source's. Run 'make all' to rebuild.",
1583ERROR_CODE_CLI_USAGE_INVALID =
"CLI_USAGE_INVALID"
1584ERROR_CODE_CFG_MISSING_SECTION =
"CFG_MISSING_SECTION"
1585ERROR_CODE_CFG_MISSING_KEY =
"CFG_MISSING_KEY"
1586ERROR_CODE_CFG_INVALID_TYPE =
"CFG_INVALID_TYPE"
1587ERROR_CODE_CFG_INVALID_VALUE =
"CFG_INVALID_VALUE"
1588ERROR_CODE_CFG_FILE_NOT_FOUND =
"CFG_FILE_NOT_FOUND"
1589ERROR_CODE_CFG_GRID_PARSE =
"CFG_GRID_PARSE"
1590ERROR_CODE_CFG_INCONSISTENT_COMBO =
"CFG_INCONSISTENT_COMBO"
1591ERROR_CODE_DEPENDENCY_MISSING =
"DEPENDENCY_MISSING"
1593RESTART_RUN_DIR_REQUIRED_MESSAGE = (
1594 "--continue and --restart-from both require an existing run directory. "
1595 "Use --continue --run-dir <run_dir> to resume in place, or "
1596 "--restart-from <run_dir> to create a new run."
1600 ERROR_CODE_CLI_USAGE_INVALID:
"Run 'picurv <command> --help' to see valid argument combinations.",
1601 ERROR_CODE_CFG_MISSING_SECTION:
"Add the missing section using examples/master_template/*.yml as reference.",
1602 ERROR_CODE_CFG_MISSING_KEY:
"Add the missing key in the referenced YAML file.",
1603 ERROR_CODE_CFG_INVALID_TYPE:
"Fix the value type to match the documented schema in docs/pages/14_Config_Contract.md.",
1604 ERROR_CODE_CFG_INVALID_VALUE:
"Adjust the value to a supported range/enum from the config reference pages.",
1605 ERROR_CODE_CFG_FILE_NOT_FOUND:
"Fix the path or create the missing file before running again.",
1606 ERROR_CODE_CFG_GRID_PARSE:
"Validate grid file format and numeric payload (block count, dims, coordinates).",
1607 ERROR_CODE_CFG_INCONSISTENT_COMBO:
"Fix conflicting options/keys so the configuration is internally consistent.",
1608 ERROR_CODE_DEPENDENCY_MISSING:
"Install the named optional dependency for the Python interpreter used by picurv.",
1614 @brief Normalize error fields into a single-line string.
1615 @param[in] value Argument passed to `_sanitize_error_field()`.
1616 @return Value returned by `_sanitize_error_field()`.
1620 text = str(value).strip()
1623 return " ".join(text.splitlines())
1627 message: str =
"", hint: str =
None, stream=
None):
1629 @brief Emit one standardized error line for tooling and users.
1630 @param[in] code Argument passed to `emit_structured_error()`.
1631 @param[in] key Argument passed to `emit_structured_error()`.
1632 @param[in] file_path Argument passed to `emit_structured_error()`.
1633 @param[in] message Argument passed to `emit_structured_error()`.
1634 @param[in] hint Argument passed to `emit_structured_error()`.
1635 @param[in] stream Argument passed to `emit_structured_error()`.
1639 resolved_hint = hint
if hint
is not None else _ERROR_HINTS.get(code,
"-")
1641 f
"ERROR {_sanitize_error_field(code)} | "
1642 f
"key={_sanitize_error_field(key)} | "
1643 f
"file={_sanitize_error_field(file_path)} | "
1644 f
"message={_sanitize_error_field(message)} | "
1645 f
"hint={_sanitize_error_field(resolved_hint)}",
1652 @brief Emit a structured CLI usage error and exit with code 2.
1653 @param[in] message Argument passed to `fail_cli_usage()`.
1654 @param[in] hint Argument passed to `fail_cli_usage()`.
1657 ERROR_CODE_CLI_USAGE_INVALID,
1661 hint=hint
or _ERROR_HINTS[ERROR_CODE_CLI_USAGE_INVALID],
1668 @brief Separate a validation error into its source-file and message fields when possible.
1669 @param[in] raw_error Validation error text, optionally beginning with a file path and colon.
1670 @return A pair containing the detected file path (or ``-``) and the message text.
1672 text = str(raw_error).strip()
1673 match = re.match(
r"^(?P<file>[^:]+):\s*(?P<msg>.+)$", text)
1676 file_candidate = match.group(
"file").strip()
1677 msg = match.group(
"msg").strip()
1678 known_suffixes = (
".yml",
".yaml",
".cfg",
".picgrid",
".control",
".run",
".txt")
1679 if "/" in file_candidate
or file_candidate.endswith(known_suffixes):
1680 return file_candidate, msg
1686 @brief Best-effort key-path extraction from free-form validation messages.
1687 @param[in] message Argument passed to `_extract_key_path()`.
1688 @return Value returned by `_extract_key_path()`.
1690 dotted = re.search(
r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_\[\]-]+)+)\b", message)
1692 return dotted.group(1)
1694 bracketed = re.search(
r"\b([A-Za-z_][A-Za-z0-9_]*\[[^\]]+\](?:\[[^\]]+\])*)\b", message)
1696 return bracketed.group(1)
1698 quoted = re.findall(
r"'([A-Za-z0-9_.\[\]-]+)'", message)
1699 for token
in quoted:
1700 if "." in token
or "[" in token
or token.isidentifier():
1707 @brief Map existing validation/error messages to the standardized code set.
1708 @param[in] message Argument passed to `_classify_error_code()`.
1709 @return Value returned by `_classify_error_code()`.
1711 msg = message.lower()
1712 if "missing required section" in msg:
1713 return ERROR_CODE_CFG_MISSING_SECTION
1714 if "missing required key" in msg
or "missing key" in msg:
1715 return ERROR_CODE_CFG_MISSING_KEY
1716 if "not found" in msg
or "does not exist" in msg:
1717 return ERROR_CODE_CFG_FILE_NOT_FOUND
1718 if "invalid dimensions line" in msg
or "invalid coordinate row" in msg
or "grid file" in msg:
1719 return ERROR_CODE_CFG_GRID_PARSE
1721 "must both be periodic" in msg
1722 or "inconsistent periodicity" in msg
1723 or "mismatch" in msg
1724 or "requires --" in msg
1725 or "must be 1 (auto) or exactly" in msg
1727 return ERROR_CODE_CFG_INCONSISTENT_COMBO
1729 "must be a mapping" in msg
1730 or "must be a list" in msg
1731 or "must be a string" in msg
1732 or "must be a boolean" in msg
1733 or "must be either" in msg
1735 return ERROR_CODE_CFG_INVALID_TYPE
1736 if "unsupported key" in msg
or "unsupported top-level section" in msg:
1737 return ERROR_CODE_CFG_INVALID_VALUE
1738 return ERROR_CODE_CFG_INVALID_VALUE
1746 @brief Safely reads a YAML file and returns its content.
1747 @param[in] filepath Path to the YAML file.
1748 @return A dictionary containing the parsed YAML content.
1749 @throws SystemExit if the file is not found or cannot be parsed.
1751 if not os.path.exists(filepath):
1753 ERROR_CODE_CFG_FILE_NOT_FOUND,
1756 message=
"Configuration file not found.",
1760 with open(filepath,
'r')
as f:
1761 return yaml.safe_load(f)
1762 except yaml.YAMLError
as e:
1764 ERROR_CODE_CFG_INVALID_VALUE,
1767 message=f
"YAML parse error: {e}",
1768 hint=
"Fix YAML syntax/indentation and retry validation.",
1774 @brief Write YAML with stable ordering for generated study artifacts.
1775 @param[in] filepath Argument passed to `write_yaml_file()`.
1776 @param[in] data Argument passed to `write_yaml_file()`.
1778 path = os.path.abspath(filepath)
1779 os.makedirs(os.path.dirname(path), exist_ok=
True)
1780 temporary = f
"{path}.tmp.{os.getpid()}"
1781 with open(temporary,
"w")
as f:
1782 yaml.safe_dump(data, f, sort_keys=
False)
1784 os.fsync(f.fileno())
1785 os.replace(temporary, path)
1789 @brief Write JSON metadata/manifests with a stable, readable format.
1790 @param[in] filepath Argument passed to `write_json_file()`.
1791 @param[in] payload Argument passed to `write_json_file()`.
1793 path = os.path.abspath(filepath)
1794 os.makedirs(os.path.dirname(path), exist_ok=
True)
1795 temporary = f
"{path}.tmp.{os.getpid()}"
1796 with open(temporary,
"w")
as f:
1797 json.dump(payload, f, indent=2, sort_keys=
True)
1800 os.fsync(f.fileno())
1801 os.replace(temporary, path)
1806 @brief Write a default runtime execution config, copying a source template when available.
1807 @param[in] filepath Argument passed to `write_runtime_execution_file()`.
1808 @param[in] template_source_path Argument passed to `write_runtime_execution_file()`.
1809 @return Value returned by `write_runtime_execution_file()`.
1811 os.makedirs(os.path.dirname(filepath), exist_ok=
True)
1813 if template_source_path
and os.path.isfile(template_source_path):
1814 shutil.copy2(template_source_path, filepath)
1817 with open(filepath,
"w", encoding=
"utf-8")
as f:
1818 f.write(DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE)
1824 @brief Return True when a launcher arg token contains embedded whitespace and should be split.
1825 @param[in] token Argument passed to `_launcher_arg_contains_whitespace()`.
1826 @return Value returned by `_launcher_arg_contains_whitespace()`.
1828 return isinstance(token, str)
and any(ch.isspace()
for ch
in token.strip())
1833 @brief Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
1834 @param[in] source_project_root Argument passed to `resolve_runtime_execution_seed_source()`.
1835 @return Value returned by `resolve_runtime_execution_seed_source()`.
1837 source_root_abs = os.path.abspath(source_project_root)
1838 repo_local_runtime = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1839 if os.path.isfile(repo_local_runtime):
1840 return repo_local_runtime
1842 tracked_example = os.path.join(
1846 RUNTIME_EXECUTION_EXAMPLE_FILENAME,
1848 if os.path.isfile(tracked_example):
1849 return tracked_example
1855 @brief Create case-local runtime execution config if missing, seeded from repo-local config when available.
1856 @param[in] case_dir Argument passed to `ensure_case_runtime_execution_config()`.
1857 @param[in] source_project_root Argument passed to `ensure_case_runtime_execution_config()`.
1858 @param[in] overwrite Argument passed to `ensure_case_runtime_execution_config()`.
1859 @return Value returned by `ensure_case_runtime_execution_config()`.
1861 case_dir_abs = os.path.abspath(case_dir)
1862 dest_path = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1863 if os.path.exists(dest_path)
and not overwrite:
1867 "seed_source":
None,
1875 "seed_source": seed_source,
1881 @brief Return True when a directory looks like the PICurv source repository root.
1882 @param[in] candidate Argument passed to `is_project_root()`.
1883 @return Value returned by `is_project_root()`.
1887 candidate_abs = os.path.abspath(candidate)
1889 os.path.isfile(os.path.join(candidate_abs,
"Makefile"))
1890 and os.path.isdir(os.path.join(candidate_abs,
"src"))
1891 and os.path.isdir(os.path.join(candidate_abs,
"include"))
1892 and os.path.isdir(os.path.join(candidate_abs,
"picurv_cli"))
1898 @brief Yield a path and all of its parents up to filesystem root.
1899 @param[in] start_path Argument passed to `_iter_parent_dirs()`.
1901 current = os.path.abspath(start_path)
1902 if os.path.isfile(current):
1903 current = os.path.dirname(current)
1906 parent = os.path.dirname(current)
1907 if parent == current:
1914 @brief Search upward from an anchor and return the first matching project root.
1915 @param[in] start_path Argument passed to `find_project_root_upwards()`.
1916 @return Value returned by `find_project_root_upwards()`.
1928 @brief Best-effort source repo discovery from runtime anchors.
1929 @param[in] extra_anchors Argument passed to `discover_local_project_root()`.
1930 @return Value returned by `discover_local_project_root()`.
1932 anchors =
list(extra_anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR, SCRIPT_PATH, PROJECT_ROOT]
1934 for anchor
in anchors:
1937 anchor_abs = os.path.abspath(anchor)
1938 if anchor_abs
in seen:
1940 seen.add(anchor_abs)
1949 @brief Find the nearest case-origin metadata file from known runtime anchors.
1950 @param[in] case_dir_hint Argument passed to `find_case_origin_metadata_file()`.
1951 @return Value returned by `find_case_origin_metadata_file()`.
1954 for candidate
in (case_dir_hint, os.getcwd(), INVOKED_SCRIPT_DIR):
1957 abs_candidate = os.path.abspath(candidate)
1958 if abs_candidate
not in search_roots:
1959 search_roots.append(abs_candidate)
1961 for root
in search_roots:
1963 metadata_path = os.path.join(directory, CASE_ORIGIN_METADATA_FILENAME)
1964 if os.path.isfile(metadata_path):
1965 return metadata_path
1971 @brief Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
1972 @param[in] case_dir_hint Argument passed to `load_case_origin_metadata()`.
1973 @return Value returned by `load_case_origin_metadata()`.
1976 if not metadata_path:
1977 return None,
None,
None
1979 with open(metadata_path,
"r", encoding=
"utf-8")
as f:
1980 payload = json.load(f)
1981 if not isinstance(payload, dict):
1982 raise ValueError(
"Case origin metadata must be a JSON object.")
1983 except Exception
as exc:
1984 raise ValueError(f
"Failed to read case origin metadata at {metadata_path}: {exc}")
from exc
1985 return os.path.dirname(metadata_path), metadata_path, payload
1990 @brief Find the nearest optional execution config from runtime/case anchors.
1991 @param[in] anchors Argument passed to `find_runtime_execution_config_file()`.
1992 @return Value returned by `find_runtime_execution_config_file()`.
1996 for candidate
in list(anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR]:
1999 current = os.path.abspath(candidate)
2000 if os.path.isfile(current):
2001 current = os.path.dirname(current)
2005 search_roots.append(current)
2008 for root
in search_roots:
2010 if directory
in seen_dirs:
2012 seen_dirs.add(directory)
2013 for filename
in RUNTIME_EXECUTION_CONFIG_FILENAMES:
2014 config_path = os.path.join(directory, filename)
2015 if os.path.isfile(config_path):
2022 @brief Validate one execution override section while preserving missing-vs-empty semantics.
2023 @param[in] payload Argument passed to `_normalize_execution_override_section()`.
2024 @param[in] section_name Argument passed to `_normalize_execution_override_section()`.
2025 @param[in] config_path Argument passed to `_normalize_execution_override_section()`.
2026 @param[in] config_label Argument passed to `_normalize_execution_override_section()`.
2027 @return Value returned by `_normalize_execution_override_section()`.
2029 section = payload.get(section_name)
2031 return {
"launcher":
None,
"launcher_args":
None}
2032 if not isinstance(section, dict):
2033 raise ValueError(f
"{config_label} at {config_path}: {section_name} must be a mapping.")
2035 launcher = section.get(
"launcher")
2036 if launcher
is not None and not isinstance(launcher, str):
2037 raise ValueError(f
"{config_label} at {config_path}: {section_name}.launcher must be a string.")
2039 launcher_args =
None
2040 if "launcher_args" in section:
2041 launcher_args = section.get(
"launcher_args", [])
2042 if launcher_args
is None:
2044 if not isinstance(launcher_args, list):
2045 raise ValueError(f
"{config_label} at {config_path}: {section_name}.launcher_args must be a list.")
2046 for i, token
in enumerate(launcher_args):
2047 if not isinstance(token, (str, int, float)):
2049 f
"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2050 "must be a scalar CLI token."
2054 f
"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2055 "must be a single CLI token; split whitespace-separated arguments into separate list items."
2057 launcher_args = [str(x)
for x
in launcher_args]
2060 "launcher": launcher,
2061 "launcher_args": launcher_args,
2067 @brief Load optional shared execution launcher config from the nearest runtime config file.
2068 @param[in] config_search_anchor Argument passed to `load_runtime_execution_config()`.
2069 @param[in] extra_search_anchors Argument passed to `load_runtime_execution_config()`.
2070 @return Value returned by `load_runtime_execution_config()`.
2073 if config_search_anchor
is not None:
2074 anchors.append(config_search_anchor)
2075 if extra_search_anchors:
2076 anchors.extend(extra_search_anchors)
2083 with open(config_path,
"r", encoding=
"utf-8")
as f:
2084 payload = yaml.safe_load(f)
or {}
2085 except yaml.YAMLError
as exc:
2086 raise ValueError(f
"{os.path.basename(config_path)} YAML parse error at {config_path}: {exc}")
from exc
2088 if not isinstance(payload, dict):
2089 raise ValueError(f
"{os.path.basename(config_path)} at {config_path} must be a YAML mapping.")
2091 return config_path, {
2094 "default_execution",
2096 os.path.basename(config_path),
2102 os.path.basename(config_path),
2106 "cluster_execution",
2108 os.path.basename(config_path),
2115 @brief Merge execution overrides, letting explicit override values win key-by-key.
2116 @param[in] base Argument passed to `merge_execution_overrides()`.
2117 @param[in] override Argument passed to `merge_execution_overrides()`.
2118 @return Value returned by `merge_execution_overrides()`.
2121 override = override
or {}
2123 launcher = override.get(
"launcher")
2124 if launcher
is None:
2125 launcher = base.get(
"launcher")
2127 launcher_args = override.get(
"launcher_args")
2128 if launcher_args
is None:
2129 launcher_args = base.get(
"launcher_args")
2132 "launcher": launcher,
2133 "launcher_args":
None if launcher_args
is None else [str(x)
for x
in launcher_args],
2139 @brief Resolve default plus context-specific execution overrides.
2140 @param[in] runtime_execution_cfg Argument passed to `resolve_runtime_execution_context()`.
2141 @param[in] context Argument passed to `resolve_runtime_execution_context()`.
2142 @return Value returned by `resolve_runtime_execution_context()`.
2144 if context
not in LAUNCH_MODES:
2145 raise ValueError(f
"Unsupported execution context '{context}'.")
2147 runtime_execution_cfg.get(
"default_execution"),
2148 runtime_execution_cfg.get(f
"{context}_execution"),
2154 @brief Best-effort git commit lookup for run/study manifests and case metadata.
2155 @param[in] repo_root Argument passed to `get_git_commit()`.
2156 @return Value returned by `get_git_commit()`.
2158 cwd = repo_root
or PROJECT_ROOT
2160 result = subprocess.run(
2161 [
"git",
"rev-parse",
"HEAD"],
2164 capture_output=
True,
2167 if result.returncode == 0:
2168 return result.stdout.strip()
2175 existing: dict =
None, template_managed_files=
None):
2177 @brief Create or refresh case-origin metadata for repo-aware case maintenance commands.
2178 @param[in] case_dir Argument passed to `write_case_origin_metadata()`.
2179 @param[in] source_project_root Argument passed to `write_case_origin_metadata()`.
2180 @param[in] template_name Argument passed to `write_case_origin_metadata()`.
2181 @param[in] existing Argument passed to `write_case_origin_metadata()`.
2182 @param[in] template_managed_files Argument passed to `write_case_origin_metadata()`.
2183 @return Value returned by `write_case_origin_metadata()`.
2185 payload = dict(existing
or {})
2186 if "initialized_at" not in payload:
2187 payload[
"initialized_at"] = datetime.now().isoformat()
2188 payload[
"source_repo_root"] = os.path.abspath(source_project_root)
2190 payload[
"template_name"] = template_name
2191 if template_managed_files
is not None:
2192 payload[
"template_managed_files"] = sorted(set(str(p)
for p
in template_managed_files))
2193 payload[
"last_known_source_git_commit"] =
get_git_commit(source_project_root)
2194 metadata_path = os.path.join(os.path.abspath(case_dir), CASE_ORIGIN_METADATA_FILENAME)
2196 return metadata_path, payload
2201 @brief Return True when make args contain an explicit target rather than only options/assignments.
2202 @param[in] make_args Argument passed to `make_args_include_explicit_goal()`.
2203 @return Value returned by `make_args_include_explicit_goal()`.
2208 options_with_value = {
2209 "-C",
"-f",
"-I",
"-j",
"-l",
"-o",
"-W",
2210 "--directory",
"--file",
"--makefile",
"--include-dir",
"--jobs",
2211 "--load-average",
"--max-load",
"--old-file",
"--assume-old",
2212 "--what-if",
"--new-file",
"--assume-new",
2214 assignment_pattern = re.compile(
r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
2217 for token
in make_args:
2221 if token
in options_with_value:
2224 if token.startswith(
"-"):
2226 if assignment_pattern.match(token):
2234 @brief Resolve case directory, source repo root, and optional template metadata.
2235 @param[in] case_dir_hint Argument passed to `resolve_case_origin_context()`.
2236 @param[in] source_root_override Argument passed to `resolve_case_origin_context()`.
2237 @param[in] template_name_override Argument passed to `resolve_case_origin_context()`.
2238 @return Value returned by `resolve_case_origin_context()`.
2242 if metadata_case_dir:
2243 case_dir = metadata_case_dir
2245 case_dir = os.path.abspath(case_dir_hint
or os.getcwd())
2247 source_project_root = source_root_override
2248 if source_project_root:
2249 source_project_root = os.path.abspath(source_project_root)
2250 elif isinstance(metadata, dict)
and isinstance(metadata.get(
"source_repo_root"), str):
2251 source_project_root = os.path.abspath(metadata[
"source_repo_root"])
2255 template_name = template_name_override
2256 if not template_name
and isinstance(metadata, dict):
2257 template_name = metadata.get(
"template_name")
2260 "case_dir": case_dir,
2261 "metadata_path": metadata_path,
2262 "metadata": metadata
or {},
2263 "source_project_root": source_project_root,
2264 "template_name": template_name,
2270 @brief Validate that a source repo root was resolved and is structurally valid.
2271 @param[in] candidate Argument passed to `require_project_root()`.
2272 @param[in] purpose Argument passed to `require_project_root()`.
2273 @return Value returned by `require_project_root()`.
2277 f
"Could not determine the PICurv source repository for {purpose}. "
2278 "Run this command from an initialized case directory or pass --source-root."
2280 candidate_abs = os.path.abspath(candidate)
2283 f
"Resolved source repository for {purpose} is not a valid PICurv root: {candidate_abs}"
2285 return candidate_abs
2290 @brief Validate that a target case directory exists and is not the source repo root.
2291 @param[in] case_dir Argument passed to `require_existing_case_dir()`.
2292 @param[in] purpose Argument passed to `require_existing_case_dir()`.
2293 @param[in] source_project_root Argument passed to `require_existing_case_dir()`.
2294 @return Value returned by `require_existing_case_dir()`.
2297 raise ValueError(f
"Could not determine the case directory for {purpose}. Pass --case-dir.")
2298 case_dir_abs = os.path.abspath(case_dir)
2299 if not os.path.isdir(case_dir_abs):
2300 raise ValueError(f
"Case directory for {purpose} does not exist: {case_dir_abs}")
2301 if source_project_root
and os.path.abspath(source_project_root) == case_dir_abs:
2303 f
"Refusing to run {purpose} against the source repository root itself: {case_dir_abs}"
2310 @brief Resolve an example template directory inside the source repository.
2311 @param[in] source_project_root Argument passed to `resolve_template_directory()`.
2312 @param[in] template_name Argument passed to `resolve_template_directory()`.
2313 @return Value returned by `resolve_template_directory()`.
2315 if not template_name:
2317 "Template name is required for config sync. Re-run with --template-name or from a case initialized by current picurv."
2319 template_dir = os.path.join(source_project_root,
"examples", template_name)
2320 if not os.path.isdir(template_dir):
2321 raise ValueError(f
"Case template '{template_name}' not found at '{template_dir}'")
2327 @brief List all files in a template directory as case-relative paths.
2328 @param[in] template_dir Argument passed to `list_template_relative_files()`.
2329 @param[in] excluded_rel_paths Argument passed to `list_template_relative_files()`.
2330 @return Value returned by `list_template_relative_files()`.
2332 template_dir_abs = os.path.abspath(template_dir)
2333 if not os.path.isdir(template_dir_abs):
2334 raise ValueError(f
"Template directory not found: {template_dir_abs}")
2335 excluded = set(excluded_rel_paths
or [])
2337 for root, _, files
in os.walk(template_dir_abs):
2338 rel_root = os.path.relpath(root, template_dir_abs)
2339 for filename
in sorted(files):
2340 rel_path = filename
if rel_root ==
"." else os.path.join(rel_root, filename)
2341 if rel_path
in excluded:
2343 relative_paths.append(rel_path)
2344 return relative_paths
2349 @brief List binary artifacts currently available in the source repo bin directory.
2350 @param[in] source_project_root Argument passed to `list_source_binaries()`.
2351 @return Value returned by `list_source_binaries()`.
2353 source_bin_dir = os.path.join(os.path.abspath(source_project_root),
"bin")
2354 if not os.path.isdir(source_bin_dir):
2355 raise ValueError(f
"Source bin directory not found: {source_bin_dir}. Run 'picurv build' first.")
2357 f
for f
in os.listdir(source_bin_dir)
2358 if os.path.isfile(os.path.join(source_bin_dir, f))
and f !=
"picurv"
2361 raise ValueError(f
"Source bin directory contains no files: {source_bin_dir}")
2362 return source_bin_dir, binaries
2367 @brief Copy current source-repo binaries into a case directory for version-pinning.
2368 @param[in] case_dir Argument passed to `sync_case_binaries()`.
2369 @param[in] source_project_root Argument passed to `sync_case_binaries()`.
2370 @return Value returned by `sync_case_binaries()`.
2372 case_dir_abs = os.path.abspath(case_dir)
2373 os.makedirs(case_dir_abs, exist_ok=
True)
2376 for binary_name
in binaries:
2377 source_path = os.path.join(source_bin_dir, binary_name)
2378 dest_path = os.path.join(case_dir_abs, binary_name)
2379 shutil.copy2(source_path, dest_path)
2380 copied.append(dest_path)
2385 prune: bool =
False, managed_rel_paths=
None):
2387 @brief Sync template files into a case directory, preserving modified files unless overwrite is requested.
2388 @param[in] case_dir Argument passed to `sync_case_template_files()`.
2389 @param[in] template_dir Argument passed to `sync_case_template_files()`.
2390 @param[in] overwrite Argument passed to `sync_case_template_files()`.
2391 @param[in] prune Argument passed to `sync_case_template_files()`.
2392 @param[in] managed_rel_paths Argument passed to `sync_case_template_files()`.
2393 @return Value returned by `sync_case_template_files()`.
2395 case_dir_abs = os.path.abspath(case_dir)
2396 template_dir_abs = os.path.abspath(template_dir)
2397 if not os.path.isdir(template_dir_abs):
2398 raise ValueError(f
"Template directory not found: {template_dir_abs}")
2403 "skipped_modified": [],
2406 "prune_requested_without_tracking":
False,
2408 excluded_rel_paths = {RUNTIME_EXECUTION_EXAMPLE_FILENAME}
2411 excluded_rel_paths=excluded_rel_paths,
2413 current_template_set = set(current_template_files)
2415 for root, _, files
in os.walk(template_dir_abs):
2416 rel_root = os.path.relpath(root, template_dir_abs)
2417 for filename
in sorted(files):
2418 src_path = os.path.join(root, filename)
2419 rel_path = filename
if rel_root ==
"." else os.path.join(rel_root, filename)
2420 if rel_path
in excluded_rel_paths:
2422 dest_path = os.path.join(case_dir_abs, rel_path)
2423 os.makedirs(os.path.dirname(dest_path), exist_ok=
True)
2425 if not os.path.exists(dest_path):
2426 shutil.copy2(src_path, dest_path)
2427 summary[
"copied"].append(dest_path)
2430 if filecmp.cmp(src_path, dest_path, shallow=
False):
2431 summary[
"unchanged"].append(dest_path)
2435 shutil.copy2(src_path, dest_path)
2436 summary[
"overwritten"].append(dest_path)
2438 summary[
"skipped_modified"].append(dest_path)
2440 managed_set = set(managed_rel_paths
or [])
2443 summary[
"prune_requested_without_tracking"] =
True
2444 for rel_path
in sorted(managed_set - current_template_set):
2445 dest_path = os.path.join(case_dir_abs, rel_path)
2446 if os.path.isfile(dest_path):
2447 os.remove(dest_path)
2448 summary[
"pruned"].append(dest_path)
2450 summary[
"template_managed_files"] = current_template_files
2456 @brief Compute source/case drift across commits, binaries, and template-managed files.
2457 @param[in] case_dir Argument passed to `compute_case_source_status()`.
2458 @param[in] source_project_root Argument passed to `compute_case_source_status()`.
2459 @param[in] template_name Argument passed to `compute_case_source_status()`.
2460 @param[in] metadata Argument passed to `compute_case_source_status()`.
2461 @return Value returned by `compute_case_source_status()`.
2463 case_dir_abs = os.path.abspath(case_dir)
2464 source_root_abs = os.path.abspath(source_project_root)
2465 metadata = metadata
or {}
2467 "case_dir": case_dir_abs,
2468 "source_repo_root": source_root_abs,
2469 "metadata_present": bool(metadata),
2470 "template_name": template_name,
2471 "last_known_source_git_commit": metadata.get(
"last_known_source_git_commit"),
2474 status[
"source_commit_changed"] = (
2475 bool(status[
"last_known_source_git_commit"])
2476 and bool(status[
"current_source_git_commit"])
2477 and status[
"last_known_source_git_commit"] != status[
"current_source_git_commit"]
2481 "source_bin_present":
False,
2482 "source_bin_missing": [],
2483 "case_bin_missing": [],
2484 "case_bin_different": [],
2485 "case_bin_current": [],
2489 binary_status[
"source_bin_present"] =
True
2490 for binary_name
in binaries:
2491 source_path = os.path.join(source_bin_dir, binary_name)
2492 case_path = os.path.join(case_dir_abs, binary_name)
2493 if not os.path.isfile(case_path):
2494 binary_status[
"case_bin_missing"].append(binary_name)
2495 elif filecmp.cmp(source_path, case_path, shallow=
False):
2496 binary_status[
"case_bin_current"].append(binary_name)
2498 binary_status[
"case_bin_different"].append(binary_name)
2499 except ValueError
as exc:
2500 binary_status[
"source_bin_missing"].append(str(exc))
2501 status[
"binaries"] = binary_status
2504 "template_available":
False,
2505 "template_files": [],
2506 "case_missing_files": [],
2507 "case_modified_files": [],
2508 "case_current_files": [],
2509 "template_removed_since_last_sync": [],
2510 "tracking_available": isinstance(metadata.get(
"template_managed_files"), list),
2517 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
2519 config_status[
"template_available"] =
True
2520 config_status[
"template_files"] = template_files
2521 for rel_path
in template_files:
2522 src_path = os.path.join(template_dir, rel_path)
2523 case_path = os.path.join(case_dir_abs, rel_path)
2524 if not os.path.isfile(case_path):
2525 config_status[
"case_missing_files"].append(rel_path)
2526 elif filecmp.cmp(src_path, case_path, shallow=
False):
2527 config_status[
"case_current_files"].append(rel_path)
2529 config_status[
"case_modified_files"].append(rel_path)
2530 managed_files = metadata.get(
"template_managed_files")
2531 if isinstance(managed_files, list):
2532 config_status[
"template_removed_since_last_sync"] = sorted(set(managed_files) - set(template_files))
2535 status[
"config"] = config_status
2537 case_runtime_cfg = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2538 repo_runtime_seed = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2540 "case_config_present": os.path.isfile(case_runtime_cfg),
2541 "repo_seed_present": os.path.isfile(repo_runtime_seed),
2542 "case_matches_repo_seed":
False,
2544 if runtime_status[
"case_config_present"]
and runtime_status[
"repo_seed_present"]:
2545 runtime_status[
"case_matches_repo_seed"] = filecmp.cmp(
2550 status[
"runtime_execution"] = runtime_status
2556 @brief Render human-readable source/case drift details.
2557 @param[in] status Argument passed to `print_case_source_status()`.
2559 print(f
"[INFO] Case directory : {status['case_dir']}")
2560 print(f
"[INFO] Source repo : {status['source_repo_root']}")
2561 print(f
"[INFO] Template : {status.get('template_name') or '(unknown)'}")
2562 if status.get(
"last_known_source_git_commit"):
2563 print(f
"[INFO] Last synced commit : {status['last_known_source_git_commit']}")
2564 if status.get(
"current_source_git_commit"):
2565 print(f
"[INFO] Current src commit : {status['current_source_git_commit']}")
2566 print(f
"[INFO] Source changed : {'yes' if status.get('source_commit_changed') else 'no'}")
2568 binaries = status[
"binaries"]
2569 if binaries[
"source_bin_present"]:
2571 f
"[INFO] Binaries : current={len(binaries['case_bin_current'])} "
2572 f
"changed={len(binaries['case_bin_different'])} missing={len(binaries['case_bin_missing'])}"
2575 print(
"[INFO] Binaries : source bin/ unavailable")
2577 config = status[
"config"]
2578 if config[
"template_available"]:
2580 f
"[INFO] Template files : current={len(config['case_current_files'])} "
2581 f
"modified={len(config['case_modified_files'])} missing={len(config['case_missing_files'])}"
2583 if config[
"tracking_available"]:
2584 print(f
"[INFO] Prune candidates : {len(config['template_removed_since_last_sync'])}")
2586 print(
"[INFO] Prune candidates : tracking unavailable")
2587 elif status.get(
"template_name"):
2588 print(
"[INFO] Template files : template unavailable in source repo")
2590 runtime_cfg = status.get(
"runtime_execution", {})
2592 f
"[INFO] Runtime config : case={'yes' if runtime_cfg.get('case_config_present') else 'no'} "
2593 f
"repo-seed={'yes' if runtime_cfg.get('repo_seed_present') else 'no'} "
2594 f
"matches-repo-seed={'yes' if runtime_cfg.get('case_matches_repo_seed') else 'no'}"
2600 @brief Report source/case drift for an initialized case directory.
2601 @param[in] args Command-line style argument list supplied to the function.
2605 case_dir_hint=getattr(args,
"case_dir",
None),
2606 source_root_override=getattr(args,
"source_root",
None),
2607 template_name_override=getattr(args,
"template_name",
None),
2613 source_project_root,
2614 template_name=context.get(
"template_name"),
2615 metadata=context.get(
"metadata"),
2617 except ValueError
as exc:
2618 print(f
"[FATAL] {exc}", file=sys.stderr)
2621 if getattr(args,
"output_format",
"text") ==
"json":
2622 print(json.dumps(status, indent=2, sort_keys=
True))
2628 @brief Resolve a potentially relative path against a source YAML file path.
2629 @param[in] anchor_file Argument passed to `resolve_path()`.
2630 @param[in] candidate Argument passed to `resolve_path()`.
2631 @return Value returned by `resolve_path()`.
2633 if candidate
is None:
2638POST_RUN_CONTROL_ALIASES = {
2639 "start_step": (
"start_step",
"startTime"),
2640 "end_step": (
"end_step",
"endTime"),
2641 "step_interval": (
"step_interval",
"timeStep"),
2645GRID_GENERATOR_HYPHEN_KEY_HINTS = {
2646 "config-file":
"config_file",
2647 "grid-type":
"grid_type",
2648 "cli-args":
"cli_args",
2654RETIRED_GENERATOR_DESTINATION_KEYS = (
"output_file",
"stats_file",
"vts_file",
2655 "output-file",
"stats-file",
"vts-file")
2660 @brief Return the first defined value from a mapping across alias keys.
2661 @param[in] mapping Argument passed to `_mapping_value_with_aliases()`.
2662 @param[in] default Argument passed to `_mapping_value_with_aliases()`.
2663 @param[in] keys Argument passed to `_mapping_value_with_aliases()`.
2664 @return Value returned by `_mapping_value_with_aliases()`.
2666 if not isinstance(mapping, dict):
2670 return mapping.get(key)
2676 @brief Resolve post run_control values with backwards-compatible legacy aliases.
2677 @param[in] post_cfg Argument passed to `get_post_run_control_value()`.
2678 @param[in] canonical_key Argument passed to `get_post_run_control_value()`.
2679 @param[in] default Argument passed to `get_post_run_control_value()`.
2680 @return Value returned by `get_post_run_control_value()`.
2682 aliases = POST_RUN_CONTROL_ALIASES.get(canonical_key, (canonical_key,))
2683 rc = post_cfg.get(
"run_control", {})
2689 @brief Warn when grid.generator uses unsupported hyphenated wrapper keys.
2690 @param[in] generator grid.generator mapping from case.yml.
2691 @param[in] case_path Case file path for diagnostics.
2692 @param[in,out] warnings Warning list to append to.
2694 if not isinstance(generator, dict):
2696 for bad_key, expected_key
in GRID_GENERATOR_HYPHEN_KEY_HINTS.items():
2697 if bad_key
in generator
and bad_key != expected_key:
2699 f
"{case_path}: grid.generator.{bad_key} is ignored; use grid.generator.{expected_key}."
2705 @brief Reject generator settings that try to choose their own output destination.
2706 @param[in] generator Generator mapping from the case configuration.
2707 @param[in] case_path Case file path for diagnostics.
2708 @param[in] label Dotted configuration path being checked, for the message.
2709 @return List of error strings.
2711 if not isinstance(generator, dict):
2714 f
" {case_path}: '{label}.{key}' is no longer accepted. PICurv chooses where "
2715 "generated artifacts go; the published asset carries the payload, its preview, "
2716 "and its validation record."
2717 for key
in RETIRED_GENERATOR_DESTINATION_KEYS
if key
in generator
2723 @brief Return source_data as a mapping when valid, else an empty mapping.
2724 @param[in] post_cfg Argument passed to `get_post_source_data()`.
2725 @return Value returned by `get_post_source_data()`.
2727 source_cfg = post_cfg.get(
"source_data", {})
2728 if isinstance(source_cfg, dict):
2735 @brief Resolve the source directory template from source_data with a safe default.
2736 @param[in] post_cfg Argument passed to `get_post_source_directory_template()`.
2737 @param[in] default Argument passed to `get_post_source_directory_template()`.
2738 @return Value returned by `get_post_source_directory_template()`.
2745 @brief Return post input_extensions, preferring io.* and tolerating legacy source_data.* placement.
2746 @param[in] post_cfg Argument passed to `get_post_input_extensions()`.
2747 @return Value returned by `get_post_input_extensions()`.
2749 io_cfg = post_cfg.get(
"io", {})
2750 io_ext = io_cfg.get(
"input_extensions")
if isinstance(io_cfg, dict)
else None
2751 if isinstance(io_ext, dict):
2755 if isinstance(source_ext, dict):
2763 @brief Return normalized statistics pipeline tokens that will be written into post.run.
2764 @param[in] post_cfg Argument passed to `get_post_statistics_task_tokens()`.
2765 @return Value returned by `get_post_statistics_task_tokens()`.
2767 stats_cfg = post_cfg.get(
"statistics_pipeline")
2769 if isinstance(stats_cfg, list):
2770 stats_entries = stats_cfg
2771 elif isinstance(stats_cfg, dict):
2772 stats_entries = stats_cfg.get(
"tasks", [])
2775 for entry
in stats_entries:
2776 if isinstance(entry, str):
2778 elif isinstance(entry, dict):
2779 task_name = entry.get(
"task")
2791 @brief Resolve the solver output root from monitor.yml, preserving the default layout.
2792 @param[in] monitor_cfg Argument passed to `get_monitor_output_directory()`.
2793 @param[in] default Argument passed to `get_monitor_output_directory()`.
2794 @return Value returned by `get_monitor_output_directory()`.
2796 del monitor_cfg, default
2797 return CANONICAL_RUN_PATHS[
"output"]
2802 @brief Resolve the statistics CSV prefix, preserving legacy top-level override support.
2803 @param[in] post_cfg Argument passed to `get_post_statistics_output_prefix()`.
2804 @param[in] default Argument passed to `get_post_statistics_output_prefix()`.
2805 @return Value returned by `get_post_statistics_output_prefix()`.
2807 stats_cfg = post_cfg.get(
"statistics_pipeline")
2808 if isinstance(stats_cfg, dict):
2809 prefix = stats_cfg.get(
"output_prefix")
2810 if isinstance(prefix, str)
and prefix.strip():
2811 return prefix.strip()
2813 legacy_prefix = post_cfg.get(
"statistics_output_prefix")
2814 if isinstance(legacy_prefix, str)
and legacy_prefix.strip():
2815 return legacy_prefix.strip()
2822 @brief Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
2823 @param[in] post_cfg Argument passed to `resolve_post_statistics_output_prefix()`.
2824 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
2825 @param[in] default Argument passed to `resolve_post_statistics_output_prefix()`.
2826 @return Value returned by `resolve_post_statistics_output_prefix()`.
2829 if os.path.isabs(prefix):
2832 if os.path.dirname(prefix):
2836 return os.path.join(CANONICAL_RUN_PATHS[
"statistics"], prefix)
2841 @brief Predict statistics CSV output paths relative to the postprocessor runtime cwd.
2842 @param[in] post_cfg Argument passed to `get_post_statistics_output_artifacts()`.
2843 @param[in] run_dir Argument passed to `get_post_statistics_output_artifacts()`.
2844 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
2845 @return Value returned by `get_post_statistics_output_artifacts()`.
2847 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
2850 "ComputeMSD":
"_msd.csv",
2853 if os.path.isabs(prefix):
2854 base_path = os.path.abspath(prefix)
2856 base_path = os.path.abspath(os.path.join(run_dir, prefix))
2860 suffix = token_to_suffix.get(token)
2862 output_paths.append(base_path + suffix)
2864 return list(dict.fromkeys(output_paths))
2875POST_SPECTRA_TASKS = {
2877 "requires_uniform_cartesian":
True,
2878 "requires_periodic_geometric":
True,
2879 "requires_single_block":
True,
2880 "fields": (
"Ucat",),
2885POST_SPECTRA_SYMBOLS = (
"continuum",
"discrete")
2889POST_SPECTRA_MEAN_MODES = (
"none",
"domain")
2893POST_FIELD_STATISTICS_OUTPUTS = (
"mean",
"reynolds_stress",
"rms",
"tke",
"flux")
2897POST_FIELD_STATISTICS_FORMATS = (
"vtk",
"csv")
2909GRID_MODES = (
"file",
"programmatic_c",
"grid_gen")
2912GRID_GENERATOR_TYPES = (
"box",
"sweep")
2918GRID_CROSS_SECTION_KINDS = (
"rectangle",
"circle")
2922GRID_WALL_SEGMENT_KINDS = (
"flat",
"step",
"ramp",
"arc",
"sine",
"gaussian",
"hill")
2925GRID_PATH_SEGMENT_KINDS = (
"straight",
"arc")
2928GRID_TRANSFORM_KINDS = (
"anchor",
"translate",
"scale",
"rotate",
"mirror",
"permute")
2931PARTICLE_RESTART_MODES = (
"init",
"load")
2934POST_EULERIAN_PIPELINE_TASKS = (
"q_criterion",
"normalize_field",
"nodal_average")
2937POST_LAGRANGIAN_PIPELINE_TASKS = (
"specific_ke",)
2940STUDY_TYPES = (
"grid_independence",
"timestep_independence",
"sensitivity")
2943NEWTON_KRYLOV_PRECONDITIONER_MODELS = (
"none",
"frozen_momentum_jacobian")
2948NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES = (
"none",
"point_block")
2952POISSON_PRECONDITIONER_TYPES = (
"multigrid",)
2955POISSON_PRECONDITIONER_SPELLINGS = {
"mg":
"multigrid",
"pcmg":
"multigrid"}
2961PRESCRIBED_FLOW_SOURCE_TYPES = (
"file",
"generated",
"field_slice")
2964VERIFICATION_SCALAR_PROFILES = (
"CONSTANT",
"LINEAR_X",
"SIN_PRODUCT")
2967STUDY_PLOT_FORMATS = (
"png",
"pdf",
"svg")
2970PROFILING_TIMESTEP_MODES = (
"off",
"selected",
"all")
2974GMRES_RESTART_METHODS = (
"gmres",
"fgmres",
"lgmres")
2977ANALYTICAL_SOLUTION_TYPES = (
"TGV3D",
"ZERO_FLOW",
"UNIFORM_FLOW")
2981LEGACY_FIELD_INIT_SPELLINGS = (
"Zero",
"Constant",
"Poiseuille")
2984PROJECTION_OPERATORS = (
"continuum",
"picurv_discrete")
2987METRIC_SOURCE_KINDS = (
"statistics_csv",
"csv",
"log_regex",
"log")
2990LAUNCH_MODES = (
"local",
"cluster")
2996 @brief Validate and canonicalize the spectra block of post.yml.
2998 @details The recipe chooses what to measure; whether the case *can* support that
2999 measurement is a separate question answered by
3000 `validate_post_spectra_preconditions()`, which needs the grid and the
3001 boundary conditions. Keeping the two apart means a recipe stays valid on
3002 its own terms even when it is read without a case beside it.
3004 @param[in] post_cfg Parsed post-processing configuration.
3005 @return Normalized mapping with an output prefix and a list of tasks.
3006 @throws ValueError on a malformed or inconsistent recipe.
3008 raw = (post_cfg
or {}).get(
"spectra")
3010 return {
"output_prefix":
"Spectrum",
"tasks": []}
3011 if not isinstance(raw, dict):
3012 raise ValueError(
"'spectra' must be a mapping.")
3014 prefix = raw.get(
"output_prefix",
"Spectrum")
3015 if not isinstance(prefix, str)
or not prefix.strip():
3016 raise ValueError(
"'spectra.output_prefix' must be a non-empty string.")
3018 tasks = raw.get(
"tasks")
3019 if not isinstance(tasks, list)
or not tasks:
3020 raise ValueError(
"'spectra.tasks' must be a non-empty list.")
3024 for position, entry
in enumerate(tasks):
3025 if not isinstance(entry, dict):
3026 raise ValueError(f
"spectra task {position}: each task must be a mapping.")
3027 name = entry.get(
"task")
3028 if name
not in POST_SPECTRA_TASKS:
3030 f
"spectra task {position}: unknown task {name!r}. "
3031 f
"Available tasks: {sorted(POST_SPECTRA_TASKS)}."
3033 spec = POST_SPECTRA_TASKS[name]
3035 field = entry.get(
"field", spec[
"fields"][0])
3036 if field
not in spec[
"fields"]:
3038 f
"spectra task '{name}': field {field!r} is not supported. "
3039 f
"Supported fields: {list(spec['fields'])}."
3042 block = entry.get(
"block", 0)
3043 if not isinstance(block, int)
or isinstance(block, bool)
or block < 0:
3044 raise ValueError(f
"spectra task '{name}': 'block' must be a non-negative integer.")
3046 symbol = entry.get(
"symbol",
"continuum")
3047 if symbol
not in POST_SPECTRA_SYMBOLS:
3049 f
"spectra task '{name}': unknown symbol {symbol!r}. "
3050 f
"Available symbols: {list(POST_SPECTRA_SYMBOLS)}."
3053 subtract_mean = entry.get(
"subtract_mean",
"none")
3054 if not isinstance(subtract_mean, str)
or not subtract_mean.strip():
3055 raise ValueError(f
"spectra task '{name}': 'subtract_mean' must be a string.")
3056 subtract_mean = subtract_mean.strip()
3057 if subtract_mean.startswith(
"window:"):
3058 window_name = subtract_mean.split(
":", 1)[1].strip()
3061 f
"spectra task '{name}': 'subtract_mean: window:<name>' needs a window name."
3063 elif subtract_mean
not in POST_SPECTRA_MEAN_MODES:
3065 f
"spectra task '{name}': unknown subtract_mean {subtract_mean!r}. "
3066 f
"Use one of {list(POST_SPECTRA_MEAN_MODES)} or 'window:<name>'."
3073 mean_source_step = entry.get(
"mean_source_step")
3074 if mean_source_step
is not None:
3075 if (
not isinstance(mean_source_step, int)
or isinstance(mean_source_step, bool)
3076 or mean_source_step < 0):
3078 f
"spectra task '{name}': 'mean_source_step' must be a non-negative integer."
3080 if not subtract_mean.startswith(
"window:"):
3082 f
"spectra task '{name}': 'mean_source_step' only applies to "
3083 f
"'subtract_mean: window:<name>'."
3086 reference = entry.get(
"reference")
3087 if reference
is not None and (
not isinstance(reference, str)
or not reference.strip()):
3088 raise ValueError(f
"spectra task '{name}': 'reference' must be a non-empty string.")
3091 identity = (name, field, block, symbol)
3092 if identity
in seen:
3094 f
"spectra recipe lists task '{name}' for field {field} on block {block} "
3095 f
"with symbol '{symbol}' more than once."
3097 seen.append(identity)
3103 "subtract_mean": subtract_mean,
3104 "mean_source_step": mean_source_step,
3105 "reference": reference.strip()
if isinstance(reference, str)
else None,
3108 return {
"output_prefix": prefix.strip(),
"tasks": cleaned}
3113 @brief Check spectra tasks against what the case can actually support.
3115 @details Runs before any field is read, so a case with no homogeneous direction is
3116 refused rather than yielding a curve that means nothing. Only the checks
3117 the case file can answer are made here: periodicity and block count. Grid
3118 uniformity is enforced by `generators/spectra.gen`, which reads the staged
3119 PICGRID and is the only place the real node coordinates are known.
3121 @param[in] spectra_cfg Normalized spectra recipe.
3122 @param[in] case_cfg Parsed case configuration.
3123 @param[in] post_path Post recipe path, for error messages.
3124 @return List of formatted error strings; empty when every task is supportable.
3127 if not spectra_cfg.get(
"tasks"):
3136 block_count = int((case_cfg.get(
"models", {})
or {}).get(
"domain", {}).get(
"blocks", 1))
3138 for block_index, block_bcs
in enumerate(prepared_blocks):
3139 faces = {entry.get(
"face")
for entry
in block_bcs
if entry.get(
"type") ==
"PERIODIC"}
3140 periodic_faces[block_index] = faces
3142 all_faces = {
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta"}
3143 for task_cfg
in spectra_cfg[
"tasks"]:
3144 spec = POST_SPECTRA_TASKS[task_cfg[
"task"]]
3145 label = f
"spectra task '{task_cfg['task']}'"
3147 if spec.get(
"requires_single_block")
and block_count != 1:
3149 f
" {post_path}: {label} requires a single-block domain; this case has "
3150 f
"{block_count} blocks."
3153 block = task_cfg[
"block"]
3154 if block >= block_count:
3156 f
" {post_path}: {label} targets block {block}, but the case defines "
3157 f
"{block_count} block(s)."
3161 if spec.get(
"requires_periodic_geometric"):
3162 missing = sorted(all_faces - periodic_faces.get(block, set()))
3165 f
" {post_path}: {label} requires every face of block {block} to be "
3166 f
"PERIODIC, because a shell-averaged spectrum is only defined for a "
3167 f
"triply periodic homogeneous box. Non-periodic faces: {missing}."
3174 @brief Locate one checkpoint payload by its inventory entry rather than by path shape.
3175 @param[in] bundle Validated checkpoint bundle mapping.
3176 @param[in] kind Payload kind recorded in the inventory.
3177 @param[in] field Payload field name recorded in the inventory.
3178 @param[in] block Block index the payload belongs to.
3179 @return Absolute path to the payload file.
3180 @throws ValueError when the bundle carries no such payload.
3182 for payload
in bundle[
"payloads"]:
3183 if (payload[
"kind"] == kind
and payload[
"field"] == field
3184 and payload[
"block"] == str(block)):
3185 return os.path.join(bundle[
"bundle"], *payload[
"path"].split(
"/"))
3187 f
"checkpoint {os.path.basename(bundle['bundle'])} carries no {kind} payload "
3188 f
"'{field}' for block {block}."
3194 @brief Build the generator arguments implementing a task's fluctuation choice.
3195 @param[in] task_cfg Normalized spectra task.
3196 @param[in] bundle Validated checkpoint bundle for the step being transformed.
3197 @param[in] mean_bundle Bundle supplying the window mean; defaults to @p bundle.
3198 @return Argument list to append to the generator command.
3199 @throws ValueError when a named window is absent from the bundle.
3201 mode = task_cfg[
"subtract_mean"]
3202 if not mode.startswith(
"window:"):
3203 return [
"--subtract-mean", mode]
3204 window = mode.split(
":", 1)[1].strip()
3205 block = task_cfg[
"block"]
3206 source = mean_bundle
or bundle
3209 return [
"--subtract-mean",
"field",
"--mean-file", mean_path,
"--count-file", count_path]
3213POST_SPECTRA_SCALAR_COLUMNS = (
3214 "resolved_kinetic_energy",
"spectrum_total_energy",
"parseval_residual",
3215 "spectrum_peak_k",
"zero_mode_energy",
"integral_length_scale",
3216 "taylor_microscale",
"dissipation_over_viscosity",
3221POST_STAGE_NAMES = (
"fields",
"spectra")
3226 @brief Resolve the `--only` selector into the set of post stages to execute.
3227 @param[in] only Comma-separated selector text, or None for every stage.
3228 @return Set of stage names.
3229 @throws ValueError when the selector names an unknown or empty stage.
3232 return set(POST_STAGE_NAMES)
3233 requested = [token.strip()
for token
in str(only).split(
",")]
3234 if not all(requested):
3235 raise ValueError(
"--only must not contain an empty stage name.")
3236 unknown = sorted({token
for token
in requested
if token
not in POST_STAGE_NAMES})
3239 f
"--only names unknown post stage(s) {unknown}. "
3240 f
"Available stages: {list(POST_STAGE_NAMES)}."
3242 return set(requested)
3246 source_dir: str, steps, quiet: bool =
False) -> dict:
3248 @brief Measure spectra for every requested task across a window of committed steps.
3250 @details One spectrum per step per task, because a spectrum is a property of one
3251 state: averaging across steps would be wrong for a decaying flow, where
3252 every step is a different statistical state. Results are written in long
3253 form so a family of curves stays one file, alongside a scalar history that
3254 plots through the ordinary series machinery.
3256 @param[in] run_dir Run directory receiving the output.
3257 @param[in] post_cfg Parsed post-processing configuration.
3258 @param[in] monitor_cfg Parsed monitor configuration anchoring the output root.
3259 @param[in] source_dir Directory holding the committed checkpoints.
3260 @param[in] steps Iterable of checkpoint steps to process, in order.
3261 @param[in] quiet Suppress progress reporting.
3262 @return Summary with the written paths and the steps actually processed.
3263 @throws ValueError when the generator fails or a requested payload is absent.
3266 if not spectra[
"tasks"]:
3267 return {
"tasks": [],
"steps": [],
"artifacts": []}
3269 script = os.path.join(GENERATORS_PATH,
"spectra.gen")
3270 if not os.path.isfile(script):
3271 raise ValueError(f
"spectra.gen script not found: {script}")
3272 staged_grid = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
3273 if not os.path.isfile(staged_grid):
3274 raise ValueError(f
"spectra require a staged PICGRID at {staged_grid}.")
3276 output_dir = os.path.join(
3279 os.makedirs(output_dir, exist_ok=
True)
3284 scale_arguments = []
3285 if bool((post_cfg.get(
"global_operations")
or {}).get(
"dimensionalize",
False)):
3287 case_path = os.path.join(run_dir, active_case)
if active_case
else None
3288 if case_path
and os.path.isfile(case_path):
3291 "--velocity-ref", repr(float(scaling[
"velocity_ref"])),
3292 "--length-ref", repr(float(scaling[
"length_ref"])),
3295 print(
"[WARNING] Spectra: dimensionalize was requested but this run carries no "
3296 "readable case snapshot; results stay non-dimensional.", file=sys.stderr)
3298 requested = sorted(set(int(step)
for step
in steps))
3304 ordered_steps = [step
for step
in requested
if step
in available]
3305 if not ordered_steps:
3307 print(
"[INFO] Spectra: no committed checkpoint in the requested window yet; "
3308 "nothing measured.")
3309 return {
"tasks": [],
"steps": [],
"artifacts": []}
3310 if not quiet
and len(ordered_steps) != len(requested):
3311 print(f
"[INFO] Spectra: {len(ordered_steps)} of {len(requested)} requested step(s) "
3312 f
"are committed; measuring those.")
3314 for task_cfg
in spectra[
"tasks"]:
3316 spectrum_path = os.path.join(output_dir, f
"{basename}.csv")
3317 scalar_path = os.path.join(output_dir, f
"{basename}_history.csv")
3321 if task_cfg[
"mean_source_step"]
is not None:
3324 for step
in ordered_steps:
3326 time = float(bundle[
"metadata"][
"checkpoint_time"])
3328 bundle,
"eulerian", task_cfg[
"field"], task_cfg[
"block"]
3330 cmd = [sys.executable, script,
"shell-spectrum",
3331 "--field-file", field_path,
"--source-grid", staged_grid,
3332 "--block", str(task_cfg[
"block"]),
"--symbol", task_cfg[
"symbol"]]
3334 cmd.extend(scale_arguments)
3335 result = subprocess.run(cmd, text=
True, capture_output=
True)
3336 if result.returncode != 0:
3337 details = (result.stderr
or result.stdout
or "").strip()
3339 f
"spectra task '{task_cfg['task']}' failed at step {step} with exit code "
3340 f
"{result.returncode}. Details:\n{details}"
3342 summary = json.loads(result.stdout)
3343 for row
in summary[
"shell_spectrum"]:
3344 spectrum_rows.append({
"step": step,
"time": time,
3345 "k": row[
"k"],
"energy": row[
"energy"]})
3346 scalar_rows.append({
"step": step,
"time": time,
3347 **{name: summary[name]
for name
in POST_SPECTRA_SCALAR_COLUMNS}})
3349 with open(spectrum_path,
"w", newline=
"", encoding=
"utf-8")
as stream:
3350 writer = csv.DictWriter(stream, fieldnames=(
"step",
"time",
"k",
"energy"))
3351 writer.writeheader()
3352 writer.writerows(spectrum_rows)
3353 with open(scalar_path,
"w", newline=
"", encoding=
"utf-8")
as stream:
3354 writer = csv.DictWriter(stream, fieldnames=(
"step",
"time") + POST_SPECTRA_SCALAR_COLUMNS)
3355 writer.writeheader()
3356 writer.writerows(scalar_rows)
3357 artifacts.extend([spectrum_path, scalar_path])
3359 print(f
"[INFO] Spectra: wrote {len(scalar_rows)} step(s) for task "
3360 f
"'{task_cfg['task']}' to {os.path.relpath(spectrum_path, run_dir)}")
3363 "tasks": [entry[
"task"]
for entry
in spectra[
"tasks"]],
3364 "steps": ordered_steps,
3365 "artifacts": artifacts,
3371 @brief Reduce a normalized spectra recipe to a stable identity string.
3372 @param[in] spectra_cfg Normalized spectra recipe.
3373 @return Hex digest covering every choice that changes the produced spectra.
3375 payload = json.dumps(spectra_cfg, sort_keys=
True, separators=(
",",
":")).encode(
"utf-8")
3376 return hashlib.sha256(payload).hexdigest()[:16]
3381 @brief Resolve the run-relative directory spectra CSVs are written to.
3382 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
3383 @return Run-relative directory path.
3386 return CANONICAL_RUN_PATHS[
"spectra"]
3391 @brief Resolve the canonical spectra directory for one versioned recipe.
3392 @param[in] post_cfg Parsed runtime post config.
3393 @param[in] monitor_cfg Optional monitor config for standalone compatibility.
3394 @return Run-relative spectra directory.
3396 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
3402 @brief Predict the spectra CSV paths a recipe will write.
3403 @param[in] post_cfg Parsed post-processing configuration.
3404 @param[in] run_dir Run directory the recipe operates on.
3405 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
3406 @return Absolute CSV paths, one per task, in recipe order.
3413 if not spectra[
"tasks"]:
3417 for task_cfg
in spectra[
"tasks"]:
3419 paths.append(os.path.join(base, f
"{name}.csv"))
3420 return list(dict.fromkeys(paths))
3425 @brief Build the file basename one normalized spectra task writes.
3426 @param[in] task_cfg Normalized task mapping.
3427 @param[in] output_prefix Recipe output prefix.
3428 @return Basename without directory or extension.
3430 parts = [output_prefix, task_cfg[
"task"], task_cfg[
"field"],
3431 f
"block{task_cfg['block']:04d}", task_cfg[
"symbol"]]
3432 return "_".join(parts)
3437 @brief Validate and canonicalize the field_statistics block of post.yml.
3439 @details The recipe names windows rather than redescribing them, because the
3440 window definitions already reach the post-processor through the run's
3441 solver control. Validation therefore covers the recipe's own choices
3442 only; a name no window matches is caught in C against the resolved list,
3443 which is the only place the real set is known.
3445 @param[in] post_cfg Parsed post-processing configuration.
3446 @return Value returned by `normalize_post_field_statistics_config()`.
3448 raw = (post_cfg
or {}).get(
"field_statistics")
3450 return {
"windows": [],
"source_step":
None,
"outputs": [],
"formats": []}
3451 if not isinstance(raw, dict):
3452 raise ValueError(
"'field_statistics' must be a mapping.")
3454 windows = raw.get(
"windows")
3455 if not isinstance(windows, list)
or not windows:
3456 raise ValueError(
"'field_statistics.windows' must be a non-empty list of window names.")
3458 for entry
in windows:
3459 if not isinstance(entry, str)
or not entry.strip():
3460 raise ValueError(
"'field_statistics.windows' entries must be non-empty window names.")
3461 name = entry.strip()
3464 raise ValueError(f
"field statistics recipe lists window '{name}' more than once.")
3465 cleaned.append(name)
3467 label =
", ".join(cleaned)
3468 source_step = raw.get(
"source_step")
3469 if source_step
is not None:
3470 if not isinstance(source_step, int)
or isinstance(source_step, bool)
or source_step < 0:
3472 f
"field statistics recipe for [{label}]: 'source_step' must be a non-negative "
3473 f
"integer (got {source_step!r})."
3476 outputs = raw.get(
"outputs",
list(POST_FIELD_STATISTICS_OUTPUTS))
3477 if not isinstance(outputs, list)
or not outputs:
3478 raise ValueError(f
"field statistics recipe for [{label}]: 'outputs' must be a non-empty list.")
3479 unknown = [item
for item
in outputs
if item
not in POST_FIELD_STATISTICS_OUTPUTS]
3482 f
"field statistics recipe for [{label}]: unknown outputs {unknown}. "
3483 f
"Available outputs: {list(POST_FIELD_STATISTICS_OUTPUTS)}."
3485 if len(set(outputs)) != len(outputs):
3486 raise ValueError(f
"field statistics recipe for [{label}]: 'outputs' lists a duplicate.")
3488 formats = raw.get(
"formats", [
"vtk"])
3489 if not isinstance(formats, list)
or not formats:
3490 raise ValueError(f
"field statistics recipe for [{label}]: 'formats' must be a non-empty list.")
3491 unknown = [item
for item
in formats
if item
not in POST_FIELD_STATISTICS_FORMATS]
3494 f
"field statistics recipe for [{label}]: unknown formats {unknown}. "
3495 f
"Available formats: {list(POST_FIELD_STATISTICS_FORMATS)}."
3497 if len(set(formats)) != len(formats):
3498 raise ValueError(f
"field statistics recipe for [{label}]: 'formats' lists a duplicate.")
3502 "source_step": source_step,
3503 "outputs":
list(outputs),
3504 "formats":
list(formats),
3510 @brief Count the derived fields one window would produce for a set of outputs.
3512 @details Mirrors the resolution the C derivation performs, so a recipe that would
3513 produce an empty file is refused before the run rather than after it. Each
3514 output resolves against what the window accumulated: a window keeping only
3515 first moments has no stresses, RMS, or turbulent kinetic energy to give.
3517 @param[in] window_cfg Normalized window definition from monitor.yml.
3518 @param[in] outputs Requested output kinds.
3519 @return Value returned by `_post_window_derived_field_count()`.
3521 fields = window_cfg.get(
"fields", [])
or []
3522 covariances = window_cfg.get(
"covariances", [])
or []
3523 with_second = [f
for f
in fields
if "second" in (f.get(
"moments")
or [])]
3525 for kind
in outputs:
3527 total += len(fields)
3528 elif kind ==
"reynolds_stress":
3529 for entry
in with_second:
3530 dof = STATISTICS_ELIGIBLE_FIELDS[entry[
"field"]][
"components"]
3531 total += 6
if dof == 3
else 1
3533 for entry
in with_second:
3534 total += STATISTICS_ELIGIBLE_FIELDS[entry[
"field"]][
"components"]
3536 total += sum(1
for entry
in with_second
3537 if STATISTICS_ELIGIBLE_FIELDS[entry[
"field"]][
"components"] == 3)
3538 elif kind ==
"flux":
3539 total += len(covariances)
3545 @brief Return whether the current post recipe derives accumulated field statistics.
3546 @param[in] post_cfg Argument passed to `_post_requests_field_statistics()`.
3547 @return Value returned by `_post_requests_field_statistics()`.
3558 @brief Predict the per-window statistics artifacts a recipe will produce.
3559 @details Returns one entry per window and format, so resume tracking can tell a
3560 half-finished window from a completed one.
3561 @param[in] post_cfg Parsed post-processing configuration.
3562 @param[in] run_dir Run directory the outputs are written under.
3563 @return List of (kind, path_prefix) tuples; kind is 'vtk' or 'csv'.
3566 if not config[
"windows"]:
3568 io_cfg = post_cfg.get(
"io", {})
or {}
3569 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
3571 visualization_prefix = io_cfg.get(
"output_filename_prefix",
"Field")
3572 csv_prefix_path =
None
3573 if internal.get(
"field_statistics_prefix"):
3574 csv_prefix_path = os.path.join(run_dir, internal[
"field_statistics_prefix"])
3576 for window
in config[
"windows"]:
3577 if "vtk" in config[
"formats"]:
3578 artifacts.append((
"vtk", os.path.join(
3579 visualization_dir, f
"{visualization_prefix}_statistics_{window}"
3581 if "csv" in config[
"formats"]:
3582 csv_base = csv_prefix_path
or os.path.join(
3583 visualization_dir, str(visualization_prefix)
3585 artifacts.append((
"csv", f
"{csv_base}_statistics_{window}.csv"))
3591 @brief Build the flat key=value mapping consumed by the C post-processor.
3592 @param[in] post_cfg Argument passed to `build_post_recipe_config()`.
3593 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
3594 @return Value returned by `build_post_recipe_config()`.
3602 eulerian_pipeline_parts = []
3603 dimensionalize = bool(post_cfg.get(
'global_operations', {}).get(
'dimensionalize',
False))
3605 eulerian_pipeline_parts.append(
'DimensionalizeAllLoadedFields')
3609 c_config[
'dimensionalize'] =
'true'
3611 for task
in post_cfg.get(
'eulerian_pipeline', []):
3612 task_name = task.get(
'task')
3613 if task_name ==
'q_criterion':
3614 eulerian_pipeline_parts.append(
'ComputeQCriterion')
3615 elif task_name ==
'normalize_field':
3616 field = task.get(
'field',
'P')
3617 eulerian_pipeline_parts.append(f
'NormalizeRelativeField:{field}')
3618 ref_point = task.get(
'reference_point', [1, 1, 1])
3619 c_config[
'reference_ip'] = ref_point[0]
3620 c_config[
'reference_jp'] = ref_point[1]
3621 c_config[
'reference_kp'] = ref_point[2]
3622 elif task_name ==
'nodal_average':
3623 in_field = task.get(
'input_field')
3624 out_field = task.get(
'output_field')
3625 if in_field
and out_field:
3626 eulerian_pipeline_parts.append(f
'CellToNodeAverage:{in_field}>{out_field}')
3628 if eulerian_pipeline_parts:
3629 c_config[
'process_pipeline'] =
";".join(eulerian_pipeline_parts)
3631 lagrangian_pipeline_parts = []
3632 for task
in post_cfg.get(
'lagrangian_pipeline', []):
3633 task_name = task.get(
'task')
3634 if task_name ==
'specific_ke':
3635 in_field = task.get(
'input_field')
3636 out_field = task.get(
'output_field')
3637 if in_field
and out_field:
3638 lagrangian_pipeline_parts.append(f
'ComputeSpecificKE:{in_field}>{out_field}')
3639 if lagrangian_pipeline_parts:
3640 c_config[
'particle_pipeline'] =
";".join(lagrangian_pipeline_parts)
3643 statistics_output_prefix =
None
3644 stats_cfg = post_cfg.get(
'statistics_pipeline')
3645 if isinstance(stats_cfg, dict):
3646 statistics_output_prefix = stats_cfg.get(
'output_prefix')
3648 if statistics_pipeline_parts:
3649 c_config[
'statistics_pipeline'] =
";".join(statistics_pipeline_parts)
3651 elif statistics_output_prefix
is None:
3652 statistics_output_prefix = post_cfg.get(
'statistics_output_prefix')
3653 if statistics_output_prefix:
3654 c_config[
'statistics_output_prefix'] = statistics_output_prefix
3656 io = post_cfg.get(
'io', {})
3657 internal_paths = post_cfg.get(
'_picurv_paths', {})
or {}
3659 if field_statistics[
"windows"]:
3660 c_config[
'field_statistics_windows'] =
",".join(field_statistics[
"windows"])
3661 c_config[
'field_statistics_outputs'] =
",".join(field_statistics[
"outputs"])
3662 c_config[
'field_statistics_formats'] =
",".join(field_statistics[
"formats"])
3665 if field_statistics[
"source_step"]
is not None:
3666 c_config[
'field_statistics_source_step'] = field_statistics[
"source_step"]
3667 if internal_paths.get(
"field_statistics_prefix"):
3668 c_config[
'field_statistics_output_prefix'] = internal_paths[
"field_statistics_prefix"]
3670 c_config[
'output_prefix'] = io.get(
'output_directory',
'viz') +
'/' + io.get(
'output_filename_prefix',
'Field')
3671 c_config[
'particle_output_prefix'] = io.get(
'output_directory',
'viz') +
'/' + io.get(
'particle_filename_prefix',
'Particle')
3672 c_config[
'output_particles'] = io.get(
'output_particles',
False)
3673 c_config[
'particle_output_freq'] = io.get(
'particle_subsampling_frequency', 1)
3674 c_config[
'output_fields_instantaneous'] =
",".join(io.get(
'eulerian_fields', []))
3675 c_config[
'particle_fields_instantaneous'] =
",".join(io.get(
'particle_fields', []))
3677 if isinstance(input_extensions, dict):
3678 for extension_name
in (
'eulerian',
'particle'):
3679 extension = input_extensions.get(extension_name)
3680 if extension
and str(extension).strip().lstrip(
'.').lower() !=
'dat':
3682 f
"post input extension '{extension_name}' must be 'dat'; "
3683 "committed checkpoint payload names are fixed."
3687 if source_directory
is not None:
3688 c_config[
'source_directory'] = source_directory
3695 if spectra[
"tasks"]:
3703 @brief Compute a stable human-readable identity for one post recipe.
3704 @param[in] post_cfg Parsed post configuration before runtime path injection.
3705 @return Filesystem-safe recipe identity.
3707 normalized = copy.deepcopy(post_cfg
or {})
3708 normalized.pop(
"_picurv_paths",
None)
3709 source = normalized.get(
"source_data")
3710 if isinstance(source, dict):
3711 source.pop(
"directory",
None)
3712 io = normalized.get(
"io")
3714 if isinstance(io, dict):
3715 io.pop(
"output_directory",
None)
3716 raw_label = io.get(
"output_filename_prefix")
3717 if isinstance(raw_label, str)
and raw_label.strip():
3718 label = re.sub(
r"[^A-Za-z0-9_.-]+",
"-", raw_label.strip()).strip(
"-")
or "post"
3719 run_control = normalized.get(
"run_control")
3720 if isinstance(run_control, dict):
3721 for key
in POST_RUN_CONTROL_ALIASES[
"start_step"] + POST_RUN_CONTROL_ALIASES[
"end_step"]:
3722 run_control.pop(key,
None)
3723 digest = hashlib.sha256(
3724 json.dumps(normalized, sort_keys=
True, separators=(
",",
":")).encode(
"utf-8")
3726 return f
"{label}-{digest}"
3731 @brief Route every post artifact into its fixed analysis or visualization home.
3732 @param[in] post_cfg Parsed user recipe.
3733 @param[in] run_dir Run receiving derived artifacts.
3734 @return Runtime-only config copy and stable recipe id.
3737 runtime = copy.deepcopy(post_cfg)
3738 if not isinstance(runtime.get(
"source_data"), dict):
3739 runtime[
"source_data"] = {}
3740 runtime[
"source_data"][
"directory"] = os.path.join(
3741 os.path.abspath(run_dir), CANONICAL_RUN_PATHS[
"output"]
3743 io = runtime.setdefault(
"io", {})
3744 io[
"output_directory"] = os.path.join(CANONICAL_RUN_PATHS[
"visualization"], recipe_id)
3745 output_prefix = io.get(
"output_filename_prefix",
"Field")
3746 stats_cfg = runtime.get(
"statistics_pipeline")
3747 if isinstance(stats_cfg, dict):
3748 basename = stats_cfg.get(
"output_prefix",
"Stats")
3749 stats_cfg[
"output_prefix"] = os.path.join(
3750 CANONICAL_RUN_PATHS[
"statistics"], recipe_id, os.path.basename(str(basename))
3752 runtime[
"_picurv_paths"] = {
3753 "recipe_id": recipe_id,
3754 "recipe_root": os.path.join(
"config",
"post-recipes", recipe_id),
3755 "visualization": io[
"output_directory"],
3756 "statistics": os.path.join(CANONICAL_RUN_PATHS[
"statistics"], recipe_id),
3757 "field_statistics_prefix": os.path.join(
3758 CANONICAL_RUN_PATHS[
"statistics"], recipe_id, str(output_prefix)
3760 "spectra": os.path.join(CANONICAL_RUN_PATHS[
"spectra"], recipe_id),
3762 return runtime, recipe_id
3767 @brief Normalize post recipe settings into a stable signature mapping.
3768 @param[in] recipe_cfg Argument passed to `normalize_post_recipe_signature()`.
3769 @return Value returned by `normalize_post_recipe_signature()`.
3772 for key, value
in (recipe_cfg
or {}).items():
3773 if key
in POST_RECIPE_SIGNATURE_EXCLUDED_KEYS
or value
is None:
3775 if isinstance(value, bool):
3776 text =
'true' if value
else 'false'
3778 text = str(value).strip()
3779 if text.lower()
in {
'true',
'false'}:
3782 signature[str(key)] = text
3788 @brief Return normalized recipe signature plus SHA-256 fingerprint.
3789 @param[in] recipe_cfg Argument passed to `compute_post_recipe_fingerprint()`.
3790 @return Value returned by `compute_post_recipe_fingerprint()`.
3793 payload = json.dumps(signature, sort_keys=
True, separators=(
',',
':')).encode(
'utf-8')
3794 return signature, hashlib.sha256(payload).hexdigest()
3799 @brief Parse an existing generated post.run file into a key/value mapping.
3800 @param[in] post_recipe_path Argument passed to `parse_post_recipe_file()`.
3801 @return Value returned by `parse_post_recipe_file()`.
3803 if not post_recipe_path
or not os.path.isfile(post_recipe_path):
3806 with open(post_recipe_path,
'r', encoding=
'utf-8', errors=
'replace')
as f:
3808 line = raw_line.strip()
3809 if not line
or line.startswith(
'#')
or '=' not in line:
3811 key, value = line.split(
'=', 1)
3812 recipe_cfg[key.strip()] = value.strip()
3818 @brief Return the JSON resume metadata path for a run directory.
3819 @param[in] run_dir Argument passed to `get_post_resume_state_path()`.
3820 @param[in] post_cfg Optional versioned post recipe configuration.
3821 @return Value returned by `get_post_resume_state_path()`.
3823 if post_cfg
is None:
3824 return os.path.join(run_dir,
'config', POST_RESUME_STATE_FILENAME)
3830 @brief Return the versioned run-local control directory for one post recipe.
3831 @param[in] run_dir Owning run root.
3832 @param[in] post_cfg Runtime post config with canonical paths, or a user recipe.
3833 @return Absolute recipe control directory.
3835 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
3837 return os.path.join(os.path.abspath(run_dir),
"config",
"post-recipes", recipe_id)
3842 @brief Return lock-wrapper related paths for a run directory.
3843 @param[in] run_dir Argument passed to `get_post_lock_paths()`.
3844 @param[in] recipe_id Optional stable recipe identity used to scope the lock.
3845 @return Value returned by `get_post_lock_paths()`.
3847 scheduler_dir = os.path.join(run_dir,
'scheduler')
3848 suffix = f
".{recipe_id}" if recipe_id
else ""
3850 'lock_file': os.path.join(scheduler_dir, f
"post{suffix}.lock"),
3851 'metadata_file': os.path.join(scheduler_dir, f
"post{suffix}.lock.json"),
3852 'wrapper_path': os.path.join(scheduler_dir, POST_LOCK_WRAPPER_FILENAME),
3858 @brief Resolve the absolute post output directory for the current recipe.
3859 @param[in] run_dir Argument passed to `_post_output_directory_abs()`.
3860 @param[in] post_cfg Argument passed to `_post_output_directory_abs()`.
3861 @return Value returned by `_post_output_directory_abs()`.
3863 io_cfg = post_cfg.get(
'io', {})
or {}
3864 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
3865 relative = internal.get(
"visualization")
or io_cfg.get(
'output_directory')
3868 return os.path.abspath(os.path.join(run_dir, relative))
3873 @brief Return whether the current post recipe expects Eulerian VTK output artifacts.
3874 @param[in] post_cfg Argument passed to `_post_requests_eulerian_output()`.
3875 @return Value returned by `_post_requests_eulerian_output()`.
3877 io_cfg = post_cfg.get(
'io', {})
or {}
3878 return bool(io_cfg.get(
'eulerian_fields'))
3883 @brief Return whether the current post recipe expects particle VTP output artifacts.
3884 @param[in] post_cfg Argument passed to `_post_requests_particle_output()`.
3885 @return Value returned by `_post_requests_particle_output()`.
3887 io_cfg = post_cfg.get(
'io', {})
or {}
3888 return bool(io_cfg.get(
'output_particles'))
and bool(io_cfg.get(
'particle_fields'))
3893 @brief Return whether the current post recipe expects statistics CSV artifacts.
3894 @param[in] post_cfg Argument passed to `_post_requests_statistics()`.
3895 @return Value returned by `_post_requests_statistics()`.
3902 @brief Return whether the current post recipe requires particle source files to be present.
3903 @param[in] post_cfg Argument passed to `_post_needs_particle_source()`.
3904 @return Value returned by `_post_needs_particle_source()`.
3906 io_cfg = post_cfg.get(
'io', {})
or {}
3907 return bool(io_cfg.get(
'output_particles'))
or bool(post_cfg.get(
'lagrangian_pipeline'))
or _post_requests_statistics(post_cfg)
3912 @brief Yield configured post-processing steps inclusively.
3913 @param[in] start_step Argument passed to `_iter_post_steps()`.
3914 @param[in] end_step Argument passed to `_iter_post_steps()`.
3915 @param[in] step_interval Argument passed to `_iter_post_steps()`.
3917 if step_interval <= 0
or end_step < start_step:
3920 while step <= end_step:
3922 step += step_interval
3927 @brief Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
3928 @param[in] post_cfg Argument passed to `resolve_post_requested_window()`.
3929 @param[in] case_cfg Optional case configuration for end-step expansion.
3930 @return Value returned by `resolve_post_requested_window()`.
3935 if end_step < 0
and case_cfg:
3936 case_run = case_cfg.get(
'run_control', {})
or {}
3937 case_start = int(case_run.get(
'start_step', 0)
or 0)
3938 case_total = int(case_run.get(
'total_steps', 0)
or 0)
3939 end_step = case_start + case_total
3940 return start_step, end_step, step_interval
3945 @brief Return a copy of post_cfg with resolved source dir and optional effective bounds.
3946 @param[in] post_cfg Argument passed to `prepare_effective_post_config()`.
3947 @param[in] resolved_source_dir Argument passed to `prepare_effective_post_config()`.
3948 @param[in] start_step Argument passed to `prepare_effective_post_config()`.
3949 @param[in] end_step Argument passed to `prepare_effective_post_config()`.
3950 @return Value returned by `prepare_effective_post_config()`.
3952 effective_cfg = copy.deepcopy(post_cfg)
3953 if not isinstance(effective_cfg.get(
'source_data'), dict):
3954 effective_cfg[
'source_data'] = {}
3955 effective_cfg[
'source_data'][
'directory'] = resolved_source_dir
3956 rc = effective_cfg.setdefault(
'run_control', {})
3957 if start_step
is not None:
3958 rc[
'start_step'] = int(start_step)
3959 if end_step
is not None:
3960 rc[
'end_step'] = int(end_step)
3961 return effective_cfg
3966 @brief Collect step numbers from VTK files named with a prefix, step suffix, and extension.
3967 @param[in] prefix_path Output path prefix before the numeric step suffix.
3968 @param[in] extension VTK file extension to match without its leading dot.
3969 @return Set of step numbers represented by matching files in the prefix directory.
3971 directory = os.path.dirname(prefix_path)
3972 if not os.path.isdir(directory):
3974 basename = os.path.basename(prefix_path)
3975 pattern = re.compile(rf
'^{re.escape(basename)}_(\d+)\.{re.escape(extension)}$')
3977 for name
in os.listdir(directory):
3978 match = pattern.match(name)
3980 steps.add(int(match.group(1)))
3986 @brief Scan step ids from the first CSV column of a statistics artifact.
3987 @param[in] csv_path Argument passed to `_scan_post_statistics_csv_steps()`.
3988 @return Value returned by `_scan_post_statistics_csv_steps()`.
3990 if not os.path.isfile(csv_path):
3993 with open(csv_path,
'r', encoding=
'utf-8', errors=
'replace', newline=
'')
as f:
3994 reader = csv.reader(f)
3998 head = str(row[0]).strip().lower()
3999 if head
in {
'step',
'timestep',
'time_step'}:
4002 if step_val
is not None:
4009 @brief Collect per-family completed-step sets for the current post recipe.
4010 @param[in] run_dir Argument passed to `collect_post_completion_families()`.
4011 @param[in] post_cfg Argument passed to `collect_post_completion_families()`.
4012 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
4013 @return Value returned by `collect_post_completion_families()`.
4015 io_cfg = post_cfg.get(
'io', {})
or {}
4020 prefix = os.path.join(output_dir_abs, io_cfg.get(
'output_filename_prefix',
'Field'))
4024 prefix = os.path.join(output_dir_abs, io_cfg.get(
'particle_filename_prefix',
'Particle'))
4043 @brief Detect the highest contiguous fully completed post step for the current recipe.
4044 @param[in] run_dir Argument passed to `detect_post_completed_frontier()`.
4045 @param[in] post_cfg Argument passed to `detect_post_completed_frontier()`.
4046 @param[in] monitor_cfg Argument passed to `detect_post_completed_frontier()`.
4047 @param[in] start_step Argument passed to `detect_post_completed_frontier()`.
4048 @param[in] end_step Argument passed to `detect_post_completed_frontier()`.
4049 @param[in] step_interval Argument passed to `detect_post_completed_frontier()`.
4050 @return Value returned by `detect_post_completed_frontier()`.
4056 if all(step
in family
for family
in families):
4061 'frontier_step': frontier,
4062 'artifact_family_count': len(families),
4068 @brief Return the complete source step nearest to a target step.
4069 @param[in] steps Candidate step numbers.
4070 @param[in] target Target step number.
4071 @return Nearest candidate, or None when no candidates exist.
4075 return min(steps, key=
lambda step: (abs(step - target), step))
4080 @brief Format an optional step number for user-facing diagnostics.
4081 @param[in] step Step number or None.
4082 @return Printable step text.
4084 return 'none' if step
is None else str(step)
4089 @brief Scan source artifacts and return steps with every file required by the recipe.
4090 @param[in] source_dir Source output root directory.
4091 @param[in] monitor_cfg Parsed monitor configuration.
4092 @param[in] post_cfg Parsed post-processing configuration.
4093 @return Tuple of complete source steps and source path metadata.
4098 source_dir, require_particles=require_particles
4100 return complete_steps, {
4101 'source_dir': os.path.abspath(source_dir),
4102 'require_particles': require_particles,
4108 @brief Build required source file paths for a single post-processing step.
4109 @param[in] step Requested step number.
4110 @param[in] source_scan Metadata returned by `_scan_complete_source_steps()`.
4111 @param[in] post_cfg Parsed post-processing configuration.
4112 @return Required source artifact paths.
4116 paths = [os.path.join(bundle,
'checkpoint.meta'), os.path.join(bundle,
'COMMITTED')]
4117 if source_scan.get(
'require_particles'):
4118 paths.append(os.path.join(bundle,
'particles',
'position.dat'))
4124 @brief Detect the highest contiguous fully available source step for live post-processing.
4125 @param[in] source_dir Argument passed to `detect_post_source_frontier()`.
4126 @param[in] monitor_cfg Argument passed to `detect_post_source_frontier()`.
4127 @param[in] post_cfg Argument passed to `detect_post_source_frontier()`.
4128 @param[in] start_step Argument passed to `detect_post_source_frontier()`.
4129 @param[in] end_step Argument passed to `detect_post_source_frontier()`.
4130 @param[in] step_interval Argument passed to `detect_post_source_frontier()`.
4131 @return Value returned by `detect_post_source_frontier()`.
4134 'first_requested_step': start_step,
4135 'first_incomplete_step':
None,
4136 'missing_files_for_first_incomplete_step': [],
4137 'closest_complete_step_to_start':
None,
4138 'closest_complete_step_to_end':
None,
4140 if step_interval <= 0
or end_step < start_step
or not os.path.isdir(source_dir):
4142 'frontier_step':
None,
4143 'diagnostic': diagnostic,
4147 diagnostic[
'closest_complete_step_to_start'] =
_nearest_step(complete_steps, start_step)
4148 diagnostic[
'closest_complete_step_to_end'] =
_nearest_step(complete_steps, end_step)
4155 require_particles=source_scan.get(
'require_particles',
False),
4159 diagnostic[
'first_incomplete_step'] = step
4160 diagnostic[
'missing_files_for_first_incomplete_step'] = [
4161 os.path.relpath(path, source_dir)
for path
in expected_paths
if not os.path.isfile(path)
4163 if not diagnostic[
'missing_files_for_first_incomplete_step']:
4164 diagnostic[
'missing_files_for_first_incomplete_step'] = [
4166 +
' (invalid bundle)'
4171 'frontier_step': frontier,
4172 'diagnostic': diagnostic,
4178 @brief Persist post resume lineage metadata for future --continue runs.
4179 @param[in] run_dir Argument passed to `persist_post_resume_state()`.
4180 @param[in] plan Argument passed to `persist_post_resume_state()`.
4181 @param[in] last_successful_requested_end_step Argument passed to `persist_post_resume_state()`.
4182 @return Value returned by `persist_post_resume_state()`.
4186 'schema_version': POST_RESUME_SCHEMA_VERSION,
4187 'run_id': plan.get(
'run_id'),
4188 'recipe_fingerprint': plan.get(
'recipe_fingerprint'),
4189 'recipe_signature': plan.get(
'recipe_signature'),
4190 'requested_start_step': plan.get(
'requested_start_step'),
4191 'requested_end_step': plan.get(
'requested_end_step'),
4192 'step_interval': plan.get(
'step_interval'),
4193 'source_directory': plan.get(
'source_data_directory'),
4194 'resume_match_source': plan.get(
'resume_match_source'),
4195 'last_successful_requested_end_step': last_successful_requested_end_step,
4196 'updated_at': datetime.now().isoformat(),
4204 @brief Return the Python wrapper used to hold an exclusive post-stage lock.
4205 @return Value returned by `_build_post_lock_wrapper_source()`.
4207 return """#!/usr/bin/env python3
4219 parser = argparse.ArgumentParser(description='PICurv post-stage lock wrapper')
4220 parser.add_argument('--lock-file', required=True)
4221 parser.add_argument('--metadata-file', required=True)
4222 parser.add_argument('--run-dir', required=True)
4223 parser.add_argument('--recipe-fingerprint', required=True)
4224 parser.add_argument('command', nargs=argparse.REMAINDER)
4225 args = parser.parse_args()
4227 command = list(args.command or [])
4228 if not command or command[0] != '--':
4229 parser.error("expected '-- <command ...>' after wrapper arguments")
4230 command = command[1:]
4232 os.makedirs(os.path.dirname(args.lock_file), exist_ok=True)
4233 fd = os.open(args.lock_file, os.O_RDWR | os.O_CREAT, 0o644)
4235 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
4236 except BlockingIOError:
4239 with open(args.metadata_file, 'r', encoding='utf-8') as handle:
4240 owner = json.load(handle)
4245 f"[FATAL] Post stage already active for {args.run_dir} "
4246 f"(pid={owner.get('pid')}, host={owner.get('host')}, started_at={owner.get('started_at')}).",
4250 print(f"[FATAL] Post stage already active for {args.run_dir}.", file=sys.stderr)
4255 'host': socket.gethostname(),
4256 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
4257 'run_dir': args.run_dir,
4258 'recipe_fingerprint': args.recipe_fingerprint,
4261 with open(args.metadata_file, 'w', encoding='utf-8') as handle:
4262 json.dump(metadata, handle, indent=2, sort_keys=True)
4266 result = subprocess.run(command)
4267 return int(result.returncode)
4270 os.remove(args.metadata_file)
4271 except FileNotFoundError:
4276if __name__ == '__main__':
4277 raise SystemExit(main())
4283 @brief Ensure the lock wrapper exists for a run directory and return its path.
4284 @param[in] run_dir Argument passed to `ensure_post_lock_wrapper()`.
4285 @return Value returned by `ensure_post_lock_wrapper()`.
4288 wrapper_path = paths[
'wrapper_path']
4290 existing_content =
None
4291 os.makedirs(os.path.dirname(wrapper_path), exist_ok=
True)
4292 if os.path.isfile(wrapper_path):
4293 with open(wrapper_path,
'r', encoding=
'utf-8', errors=
'replace')
as f:
4294 existing_content = f.read()
4295 if existing_content != content:
4296 with open(wrapper_path,
'w', encoding=
'utf-8')
as f:
4298 os.chmod(wrapper_path, 0o755)
4302def build_post_locked_command(run_dir: str, recipe_fingerprint: str, wrapped_command: list, create_wrapper: bool =
True) ->
"tuple[list, dict]":
4304 @brief Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
4305 @param[in] run_dir Argument passed to `build_post_locked_command()`.
4306 @param[in] recipe_fingerprint Argument passed to `build_post_locked_command()`.
4307 @param[in] wrapped_command Argument passed to `build_post_locked_command()`.
4308 @param[in] create_wrapper Argument passed to `build_post_locked_command()`.
4309 @return Value returned by `build_post_locked_command()`.
4315 '--lock-file', lock_paths[
'lock_file'],
4316 '--metadata-file', lock_paths[
'metadata_file'],
4317 '--run-dir', run_dir,
4318 '--recipe-fingerprint', recipe_fingerprint,
4320 ] +
list(wrapped_command)
4321 return command, lock_paths
4330 continue_requested: bool =
False,
4331 allow_source_frontier_scan: bool =
True,
4334 @brief Resolve post resume/source-availability behavior into one execution plan.
4335 @param[in] run_dir Argument passed to `build_post_execution_plan()`.
4336 @param[in] run_id Argument passed to `build_post_execution_plan()`.
4337 @param[in] case_cfg Argument passed to `build_post_execution_plan()`.
4338 @param[in] monitor_cfg Argument passed to `build_post_execution_plan()`.
4339 @param[in] post_cfg Argument passed to `build_post_execution_plan()`.
4340 @param[in] continue_requested Argument passed to `build_post_execution_plan()`.
4341 @param[in] allow_source_frontier_scan Argument passed to `build_post_execution_plan()`.
4342 @return Value returned by `build_post_execution_plan()`.
4344 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
4354 state_match = bool(isinstance(state_payload, dict)
and state_payload.get(
'recipe_fingerprint') == recipe_fingerprint)
4356 legacy_post_run_path = os.path.join(run_dir,
'config',
'post.run')
4359 legacy_match = bool(legacy_recipe_signature
and legacy_recipe_signature == recipe_signature)
4361 resume_recipe_match =
False
4362 resume_match_source =
None
4363 resume_bootstrapped =
False
4364 if continue_requested:
4366 resume_recipe_match =
True
4367 resume_match_source =
'state'
4368 elif not state_payload
and legacy_match:
4369 resume_recipe_match =
True
4370 resume_match_source =
'legacy_post_run'
4371 resume_bootstrapped =
True
4377 requested_start_step,
4381 completed_frontier_step = completion_info[
'frontier_step']
4382 if completion_info[
'artifact_family_count'] == 0
and state_match:
4383 completed_frontier_step =
_parse_int_loose(state_payload.get(
'last_successful_requested_end_step'))
4385 if continue_requested
and resume_recipe_match
and completed_frontier_step
is not None:
4386 effective_start_step = completed_frontier_step + step_interval
4388 effective_start_step = requested_start_step
4390 source_frontier_step =
None
4391 source_frontier_diagnostic =
None
4392 source_frontier_deferred =
not allow_source_frontier_scan
4394 if effective_start_step > requested_end_step:
4395 skip_reason =
'already-complete-window'
4396 effective_end_step = requested_end_step
4397 elif allow_source_frontier_scan:
4399 resolved_source_dir,
4402 effective_start_step,
4406 source_frontier_step = source_frontier_info[
'frontier_step']
4407 source_frontier_diagnostic = source_frontier_info[
'diagnostic']
4408 if source_frontier_step
is None or source_frontier_step < effective_start_step:
4409 if continue_requested
and resume_recipe_match
and completed_frontier_step
is not None:
4410 skip_reason =
'already-caught-up-to-current-source-frontier'
4412 skip_reason =
'nothing-available-yet'
4413 effective_end_step =
None
4415 effective_end_step = min(requested_end_step, source_frontier_step)
4417 effective_end_step = requested_end_step
4419 effective_post_cfg =
None
4420 if skip_reason
is None:
4423 resolved_source_dir,
4424 start_step=effective_start_step,
4425 end_step=effective_end_step,
4430 'continue_requested': bool(continue_requested),
4431 'requested_start_step': requested_start_step,
4432 'requested_end_step': requested_end_step,
4433 'step_interval': step_interval,
4434 'source_data_directory': resolved_source_dir,
4435 'recipe_config': recipe_cfg,
4436 'recipe_signature': recipe_signature,
4437 'recipe_fingerprint': recipe_fingerprint,
4438 'resume_state_path': state_path,
4439 'resume_state_payload': state_payload,
4440 'resume_recipe_match': resume_recipe_match,
4441 'resume_match_source': resume_match_source,
4442 'resume_bootstrapped': resume_bootstrapped,
4443 'completed_frontier_step': completed_frontier_step,
4444 'source_frontier_step': source_frontier_step,
4445 'source_frontier_diagnostic': source_frontier_diagnostic,
4446 'source_frontier_deferred': source_frontier_deferred,
4447 'effective_start_step': effective_start_step,
4448 'effective_end_step': effective_end_step,
4449 'skip_reason': skip_reason,
4450 'resolved_post_cfg': resolved_post_cfg,
4451 'effective_post_cfg': effective_post_cfg,
4453 run_dir, ((resolved_post_cfg.get(
"_picurv_paths")
or {}).get(
"recipe_id"))
4460 @brief Return True when the solver requires restart data from disk.
4461 @details Correctly identifies that analytical + init + start_step > 0 does NOT
4462 need a restart source (C code never reads from restart_dir in that case).
4463 @param[in] case_cfg Parsed case YAML dictionary.
4464 @param[in] solver_cfg Parsed solver YAML dictionary.
4465 @return True if a restart source (--restart-from or --continue) is required.
4468 start_step = int(case_cfg.get(
"run_control", {}).get(
"start_step", 0)
or 0)
4469 except (TypeError, ValueError):
4471 eulerian_source = str(
4472 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
4474 particle_restart_mode = str(
4475 (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"particles", {})
or {}).get(
"restart_mode",
"init")
4477 euler_needs = (eulerian_source ==
"load")
or (eulerian_source ==
"solve" and start_step > 0)
4478 particle_needs = (particle_restart_mode ==
"load")
4479 return euler_needs
or particle_needs
4484 @brief Resolve the output data directory within a run directory.
4485 @param[in] run_dir Path to the run directory.
4486 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4487 @return Absolute path to the output directory.
4490 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"]))
4495 @brief Resolve the restart staging directory within a run directory.
4496 @param[in] run_dir Path to the run directory.
4497 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4498 @return Absolute path to the restart directory.
4501 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS[
"restart"]))
4506 @brief Compute the hidden identity used to guard in-place continuation.
4507 @details Run length/timestep controls and particle load-vs-init policy do
4508 not define the physical case. All other case.yml content does.
4509 @param[in] case_cfg Parsed case configuration.
4510 @return Lowercase SHA-256 identity of normalized physical-case content.
4512 normalized = copy.deepcopy(case_cfg)
4513 if isinstance(normalized, dict):
4514 normalized.pop(
"run_control",
None)
4515 particles = (((normalized.get(
"models")
or {}).get(
"physics")
or {}).get(
"particles"))
4516 if isinstance(particles, dict):
4517 particles.pop(
"restart_mode",
None)
4518 payload = json.dumps(
4519 normalized, sort_keys=
True, separators=(
",",
":"), ensure_ascii=
False
4521 return hashlib.sha256(payload).hexdigest()
4526 @brief Reject in-place continuation when the physical case has changed.
4527 @param[in] run_dir Existing run directory being continued.
4528 @param[in] case_cfg Newly requested case configuration.
4531 saved_case_path = os.path.join(run_dir,
"config",
"case.yml")
4532 if not os.path.isfile(saved_case_path):
4533 raise ValueError(f
"--continue run is missing its saved case.yml: {saved_case_path}")
4537 "--continue cannot change the physical case. Change only run_control, "
4538 "solver.yml, monitor.yml, or post.yml; use --restart-from for a new case branch."
4543 monitor_cfg: dict, end_step:
"int | None" =
None,
4544 materialize: bool =
True):
4546 @brief Atomically materialize an immutable checkpoint interval into a run.
4547 @param[in] source_output Path to the source output directory containing checkpoint data.
4548 @param[in] target_restart Path to the target restart directory to populate.
4549 @param[in] start_step First checkpoint step to materialize.
4550 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
4551 @param[in] end_step Optional inclusive last checkpoint step.
4552 @param[in] materialize Whether to copy the bundles, or only validate and resolve
4553 the path a real run would use. The dry-run planner needs the second:
4554 it reports what a run would refuse, and promises to write nothing.
4555 @return Canonical restart root containing committed checkpoint bundles.
4558 checkpoints_root = os.path.join(os.path.abspath(target_restart),
"checkpoints")
4560 os.makedirs(checkpoints_root, exist_ok=
True)
4561 final_step = start_step
if end_step
is None else end_step
4562 if final_step < start_step:
4563 raise ValueError(
"Restart checkpoint interval end must not precede its start.")
4564 for step
in range(start_step, final_step + 1):
4568 destination = os.path.join(
4569 checkpoints_root, f
"step_{step:0{CHECKPOINT_STEP_WIDTH}d}"
4571 if os.path.isdir(destination):
4573 print(f
"[INFO] Reusing committed restart bundle: {destination}")
4575 temporary = os.path.join(
4577 f
".step_{step:0{CHECKPOINT_STEP_WIDTH}d}.copying.{os.getpid()}",
4579 if os.path.exists(temporary):
4580 raise ValueError(f
"Restart staging path already exists: {temporary}")
4582 os.makedirs(temporary)
4583 for current, dirnames, filenames
in os.walk(source):
4584 relative = os.path.relpath(current, source)
4585 target_dir = temporary
if relative ==
"." else os.path.join(temporary, relative)
4586 os.makedirs(target_dir, exist_ok=
True)
4587 for dirname
in dirnames:
4588 os.makedirs(os.path.join(target_dir, dirname), exist_ok=
True)
4589 for filename
in filenames:
4591 os.path.join(current, filename), os.path.join(target_dir, filename)
4594 os.replace(temporary, destination)
4596 if os.path.isdir(temporary):
4597 shutil.rmtree(temporary)
4599 print(f
"[INFO] Materialized committed restart bundle for step {step}: {destination}")
4600 return os.path.abspath(target_restart)
4605 @brief Validate the mandatory Eulerian field set required by `ReadSimulationFields()`.
4606 @param[in] source_dir Root directory containing the Eulerian subdirectory.
4607 @param[in] step Checkpoint step to validate.
4608 @param[in] monitor_cfg Monitor configuration defining the Eulerian subdirectory.
4609 @return Validated checkpoint description.
4610 @throws ValueError if any mandatory Eulerian field is absent.
4618 @brief Scan output directory for the highest step number available.
4619 @details Ignores incomplete temporary directories and invalid bundles.
4620 @param[in] output_dir Path to the output directory.
4621 @return The highest step number found, or None if no checkpoints exist.
4624 return max(steps)
if steps
else None
4629 @brief Determine whether a study case is complete, partially complete, or empty.
4630 @param[in] run_dir Path to the case run directory.
4631 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4632 @param[in] target_final_step The step number the case should reach for completion.
4633 @return Dictionary with keys 'last_step' (int or None), 'target_step' (int),
4634 and 'status' ('complete', 'partial', or 'empty').
4638 if last_step
is not None and last_step >= target_final_step:
4640 elif last_step
is not None:
4644 return {
"last_step": last_step,
"target_step": target_final_step,
"status": status}
4649 @brief Validate that all required eulerian step files exist for "load" mode.
4650 @details Checks that ufield files exist for every step from start_step through
4651 start_step + total_steps (inclusive). Reports missing steps clearly.
4652 @param[in] source_output Path to the output directory containing eulerian data.
4653 @param[in] start_step First step that will be loaded.
4654 @param[in] total_steps Number of steps to run.
4655 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
4658 for step
in range(start_step, start_step + total_steps + 1):
4662 missing.append(step)
4665 sample = missing[:3] + ([
"..."]
if len(missing) > 6
else []) + missing[-3:]
4667 f
"Eulerian 'load' mode: {len(missing)} committed checkpoint(s) missing in {source_output}. "
4668 f
"Missing steps include: {sample}"
4674 @brief Validate that particle checkpoint files exist for the given step.
4675 @details Checks that at least a position file exists at the expected step in
4676 the particle subdirectory.
4677 @param[in] source_dir Path to the directory containing the particle subdirectory.
4678 @param[in] start_step The step number whose particle checkpoint is expected.
4679 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
4680 @return Validated checkpoint description.
4688 @brief Read the monitor.yml from a run directory's config/ subdirectory.
4689 @param[in] run_dir Path to the run directory.
4690 @return Parsed monitor YAML dictionary.
4692 monitor_path = os.path.join(run_dir,
"config",
"monitor.yml")
4693 if not os.path.isfile(monitor_path):
4694 raise ValueError(f
"Run directory is missing config/monitor.yml: {monitor_path}")
4700 @brief Select the newest local workspace run compatible with a requested restart.
4701 @param[in] case_cfg Current case configuration.
4702 @param[in] case_path Current case path.
4703 @param[in] start_step Required committed checkpoint.
4704 @return Absolute source run directory.
4707 if not workspace_root:
4708 raise ValueError(
"--from latest requires an initialized workspace.")
4710 current_grid = next(
4711 (item
for item
in current_graph.get(
"providers", [])
if item.get(
"kind") ==
"grid"),
4715 for candidate
in Path(workspace_root,
"runs").iterdir():
4716 if not candidate.is_dir():
4719 lock =
read_yaml_file(str(candidate /
"inputs" /
"assets.lock.yml")) \
4720 if (candidate /
"inputs" /
"assets.lock.yml").is_file()
else {}
4721 locked_grid = (lock.get(
"assets")
or {}).get(
"grid")
4722 runtime_grid = (lock.get(
"runtime_providers")
or {}).get(
"grid")
4724 recorded_hash =
None
4725 if isinstance(locked_grid, dict):
4726 recorded_hash = locked_grid.get(
"provider_spec_sha256")
4727 elif isinstance(runtime_grid, dict):
4728 recorded_hash = runtime_grid.get(
"provider_spec_sha256")
4729 if recorded_hash
and recorded_hash != current_grid.get(
"spec_sha256"):
4731 output_root = candidate / CANONICAL_RUN_PATHS[
"output"]
4734 except (OSError, ValueError):
4736 ordering = manifest.get(
"updated_at")
or manifest.get(
"created_at")
or ""
4737 candidates.append((ordering, candidate.stat().st_mtime_ns, str(candidate)))
4740 f
"No local workspace run has a compatible committed checkpoint at step {start_step}. "
4741 "Restore a run from storage first, or name one explicitly with --from <run-dir>."
4743 candidates.sort(reverse=
True)
4744 selected = candidates[0][2]
4745 print(f
"[INFO] Selected latest compatible restart run: {os.path.relpath(selected, workspace_root)}")
4750 run_dir: str, materialize: bool =
True):
4752 @brief Resolve the restart source directory based on --restart-from or --continue CLI flags.
4753 @details Implements the full restart resolution logic including smart resolution for
4754 --continue (checks restart/ first for user-curated data, falls back to output/)
4755 and direct reference for eulerian "load" mode.
4756 @param[in] args Parsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
4757 @param[in] case_cfg Parsed case YAML dictionary.
4758 @param[in] solver_cfg Parsed solver YAML dictionary.
4759 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4760 @param[in] run_dir Path to the current run directory.
4761 @param[in] materialize Whether to copy restart bundles into the run. The dry-run
4762 planner passes False: it still validates and resolves, but writes nothing.
4763 @return Tuple of (restart_source_dir, continue_mode, lineage) where restart_source_dir
4764 is the resolved path (or None), continue_mode is a boolean, and lineage is the
4765 branch provenance record from `build_run_lineage()`, or None when this run did
4766 not branch from another.
4769 start_step = int(case_cfg.get(
"run_control", {}).get(
"start_step", 0)
or 0)
4770 except (TypeError, ValueError):
4773 total_steps = int(case_cfg.get(
"run_control", {}).get(
"total_steps", 0)
or 0)
4774 except (TypeError, ValueError):
4777 eulerian_source = str(
4778 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
4781 particle_restart_mode = str(
4782 (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"particles", {})
or {}).get(
"restart_mode",
"init")
4784 particle_needs = (particle_restart_mode ==
"load")
4787 restart_from = getattr(args,
'restart_from',
None)
4788 continue_run = getattr(args,
'continue_run',
False)
4790 if restart_from
and continue_run:
4791 raise ValueError(
"--restart-from and --continue are mutually exclusive.")
4793 if continue_run
and start_step <= 0:
4795 "--continue with --solve requires run_control.start_step > 0; "
4796 "start_step=0 is a fresh start. Omit --continue to start a fresh run."
4800 requested_restart_source = str(restart_from)
4801 if str(restart_from).strip().lower() ==
"latest":
4803 case_cfg, getattr(args,
"case",
"case.yml"), start_step
4811 requested_statistics_state = getattr(args,
"statistics_state",
None)
4813 if requested_statistics_state
is None:
4814 if statistics_enabled:
4816 "A branched restart of a run with field_statistics.enabled: true must "
4817 "state what happens to the parent's accumulated windows. Pass "
4818 "--statistics-state reset to discard them and start the averages over, "
4819 "or --statistics-state carry to resume compatible saved window state."
4821 requested_statistics_state =
"reset"
4822 statistics_state = str(requested_statistics_state).lower()
4823 if statistics_state ==
"carry" and not statistics_enabled:
4824 raise ValueError(
"--statistics-state carry requires field_statistics.enabled: true.")
4827 source_run = os.path.abspath(restart_from)
4828 if not os.path.isdir(source_run):
4829 raise ValueError(f
"--restart-from run directory does not exist: {source_run}")
4831 require_storage_payload_local(source_run,
"--restart-from", checkpoint=start_step)
4832 except StorageError
as exc:
4833 raise ValueError(str(exc))
from exc
4836 if not os.path.isdir(source_output):
4837 raise ValueError(f
"Source output directory does not exist: {source_output}")
4839 source_run, start_step,
4841 statistics_state=statistics_state,
4842 requested_source=requested_restart_source,
4845 if not requires_source:
4848 "[WARN] --restart-from specified but no data will be read "
4849 "(analytical + init does not need restart data).",
4852 return None,
False,
None
4854 if eulerian_source ==
"load":
4860 source_output, target_restart, start_step, monitor_cfg,
4861 end_step=start_step + total_steps, materialize=materialize,
4865 restart_root
if materialize
else source_output, start_step, monitor_cfg
4867 return restart_root,
False, lineage
4872 source_output, target_restart, start_step, monitor_cfg,
4873 materialize=materialize,
4877 restart_root
if materialize
else source_output, start_step, monitor_cfg
4879 return restart_root,
False, lineage
4883 continue_run_dir = getattr(args,
'run_dir',
None)
4884 if not continue_run_dir:
4885 raise ValueError(RESTART_RUN_DIR_REQUIRED_MESSAGE)
4886 continue_run_dir = os.path.abspath(continue_run_dir)
4887 if not os.path.isdir(continue_run_dir):
4888 raise ValueError(f
"--run-dir does not exist: {continue_run_dir}")
4890 require_storage_payload_local(continue_run_dir,
"--continue", checkpoint=start_step)
4891 except StorageError
as exc:
4892 raise ValueError(str(exc))
from exc
4898 if last_step
is not None and last_step != start_step:
4900 f
"[WARN] start_step={start_step} but last checkpoint in output is step {last_step}.",
4904 if eulerian_source ==
"load":
4909 source_output, target_restart, start_step, monitor_cfg,
4910 end_step=start_step + total_steps, materialize=materialize,
4914 restart_root
if materialize
else source_output, start_step, monitor_cfg
4916 return restart_root,
True,
None
4917 elif not requires_source:
4919 return None,
True,
None
4925 source_output, target_restart, start_step, monitor_cfg,
4926 materialize=materialize,
4929 restart_root
if materialize
else source_output, start_step,
4930 require_particles=particle_needs,
4932 return restart_root,
True,
None
4934 elif requires_source:
4936 "Restart data required but no source specified. Use:\n"
4937 " --restart-from <run_dir> (new run from another run's data)\n"
4938 " --continue --run-dir <run_dir> (resume in same directory)"
4941 return None,
False,
None
4945 @brief Convert external grid/generator paths in case config to absolute paths.
4946 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
4947 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
4954 grid_cfg = case_cfg.get(
"grid", {})
4955 if not isinstance(grid_cfg, dict):
4957 mode = grid_cfg.get(
"mode")
4959 source_file = grid_cfg.get(
"source_file")
4960 if isinstance(source_file, str):
4961 grid_cfg[
"source_file"] =
resolve_path(case_anchor_path, source_file)
4962 elif mode ==
"grid_gen":
4963 gen = grid_cfg.get(
"generator", {})
4964 if isinstance(gen, dict):
4965 for key
in (
"script",
"config_file"):
4967 if isinstance(val, str):
4969 ic = (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {})
4970 if isinstance(ic, dict):
4971 if str(ic.get(
"mode",
"")).strip().lower() ==
"file":
4972 source_file = ic.get(
"source_file")
4973 if isinstance(source_file, str):
4974 ic[
"source_file"] =
resolve_path(case_anchor_path, source_file)
4975 elif str(ic.get(
"generator",
"")).strip().lower() ==
"ic_gen":
4976 params = ic.get(
"params", {})
4977 if isinstance(params, dict):
4978 for key
in (
"script",
"config_file"):
4979 value = params.get(key)
4980 if isinstance(value, str):
4982 boundary_conditions = case_cfg.get(
"boundary_conditions", [])
4983 blocks = boundary_conditions
if boundary_conditions
and isinstance(boundary_conditions[0], list)
else [boundary_conditions]
4984 for block
in blocks:
4985 if not isinstance(block, list):
4988 if not isinstance(bc, dict)
or str(bc.get(
"handler",
"")).strip().lower() !=
"prescribed_flow":
4990 source = ((bc.get(
"params")
or {}).get(
"source")
or {})
4991 if not isinstance(source, dict):
4993 source_type = str(source.get(
"type",
"")).strip().lower()
4994 if source_type ==
"file":
4996 elif source_type ==
"generated":
4998 elif source_type ==
"field_slice":
4999 keys = (
"script",
"field_file",
"grid_file",
"source_case")
5003 value = source.get(key)
5004 if isinstance(value, str):
5009 target_final_step: int, cluster_cfg: dict):
5011 @brief Set up a partially-completed study case for continuation in-place.
5012 @details Updates the case config with new start_step/total_steps, sets particle
5013 restart_mode to 'load' if checkpoint exists, populates the restart
5014 directory, and regenerates the solver control file with continue_mode.
5015 Delegates all restart resolution to resolve_restart_source().
5016 @param[in] run_dir Path to the case run directory.
5017 @param[in] case_id The case identifier (e.g. 'case_0002').
5018 @param[in] last_step The last checkpoint step found in the output directory.
5019 @param[in] target_final_step The step number the case should reach for completion.
5020 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
5021 @return The absolute path to the regenerated control file.
5023 config_dir = os.path.join(run_dir,
"config")
5025 solver_cfg =
read_yaml_file(os.path.join(config_dir,
"solver.yml"))
5026 monitor_cfg =
read_yaml_file(os.path.join(config_dir,
"monitor.yml"))
5028 remaining = target_final_step - last_step
5029 case_cfg[
"run_control"][
"start_step"] = last_step
5030 case_cfg[
"run_control"][
"total_steps"] = remaining
5031 print(f
"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
5034 particles_cfg = (case_cfg.get(
"models", {}).get(
"physics", {})
or {}).get(
"particles")
5035 checkpoint_has_particles =
False
5038 output_dir, last_step, require_particles=
True
5041 checkpoint_has_particles =
False
5042 if particles_cfg
is not None and checkpoint_has_particles:
5043 current_mode = str(particles_cfg.get(
"restart_mode",
"init")).strip().lower()
5044 if current_mode !=
"load":
5045 particles_cfg[
"restart_mode"] =
"load"
5046 print(f
"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
5050 mock_args = argparse.Namespace(restart_from=
None, continue_run=
True, run_dir=run_dir)
5052 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
5056 'Case': os.path.join(config_dir,
"case.yml"),
5057 'Solver': os.path.join(config_dir,
"solver.yml"),
5058 'Monitor': os.path.join(config_dir,
"monitor.yml"),
5063 "case": case_cfg,
"case_path": source_files[
'Case'],
5064 "solver": solver_cfg,
"solver_path": source_files[
'Solver'],
5065 "monitor": monitor_cfg,
"monitor_path": source_files[
'Monitor'],
5069 run_dir, case_id, configs, cluster_tasks, monitor_files,
5070 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
5072 print(f
"[SUCCESS] {case_id}: regenerated control file for continuation")
5078 @brief Lightweight email validation for scheduler notifications.
5079 @param[in] email Argument passed to `is_valid_email()`.
5080 @return Value returned by `is_valid_email()`.
5082 if not isinstance(email, str):
5084 pattern =
r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
5085 return re.match(pattern, email.strip())
is not None
5089 @brief Normalizes user-facing statistics task names to C pipeline keywords.
5090 @param[in] task_name Task name from YAML.
5091 @return Canonical keyword accepted by C statistics pipeline.
5092 @throws ValueError if task is unsupported.
5095 if task_name
is None:
5096 raise ValueError(
"statistics task cannot be None")
5097 normalized = str(task_name).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
5098 if normalized !=
"msd":
5099 raise ValueError(f
"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
5104 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
5105 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
5107 for lineno, raw
in enumerate(file_obj, start=1):
5109 if not line
or line.startswith(
"#"):
5113PICGRID_FLOAT_FORMAT =
".17e"
5118 @brief Format a coordinate with round-trip-safe binary64 precision.
5119 @param[in] value Coordinate value.
5120 @return Formatted coordinate.
5122 return format(value, PICGRID_FLOAT_FORMAT)
5127 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
5128 @details Requires canonical PICGRID input with leading "PICGRID" token.
5129 Output is always written in canonical PICGRID format with header and per-block dims.
5130 @param[in] source_grid Input grid file path.
5131 @param[in] dest_grid Output grid file path.
5132 @param[in] L_ref Reference length for non-dimensionalization.
5133 @param[in] expected_nblk Optional expected block count.
5134 @return Summary dictionary with nblk, dims, and total_nodes.
5135 @throws ValueError on malformed grid.
5138 raise ValueError(
"length_ref must be non-zero when processing grid coordinates.")
5139 if not os.path.isfile(source_grid):
5140 raise ValueError(f
"Grid file not found: {source_grid}")
5142 with open(source_grid,
"r")
as fin:
5145 _, first_token = next(line_iter)
5146 except StopIteration:
5147 raise ValueError(f
"Grid file '{source_grid}' is empty.")
5149 if first_token !=
"PICGRID":
5151 f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
5154 _, nblk_line = next(line_iter)
5155 except StopIteration:
5156 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
5159 nblk = int(nblk_line)
5161 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
5163 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
5164 if expected_nblk
is not None and nblk != expected_nblk:
5166 f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
5170 for bi
in range(nblk):
5172 lineno, dim_line = next(line_iter)
5173 except StopIteration:
5174 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
5175 parts = dim_line.split()
5178 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
5181 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
5184 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
5186 if im <= 0
or jm <= 0
or km <= 0:
5188 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
5190 dims.append((im, jm, km))
5192 total_nodes_expected = sum(im * jm * km
for (im, jm, km)
in dims)
5193 os.makedirs(os.path.dirname(dest_grid), exist_ok=
True)
5194 with open(dest_grid,
"w")
as fout:
5195 fout.write(
"PICGRID\n")
5196 fout.write(f
"{nblk}\n")
5197 for (im, jm, km)
in dims:
5198 fout.write(f
"{im} {jm} {km}\n")
5200 total_nodes_seen = 0
5201 for lineno, coord_line
in line_iter:
5202 parts = coord_line.split()
5205 f
"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
5208 x = float(parts[0]) / L_ref
5209 y = float(parts[1]) / L_ref
5210 z = float(parts[2]) / L_ref
5213 f
"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
5215 total_nodes_seen += 1
5216 if total_nodes_seen > total_nodes_expected:
5218 f
"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
5221 f
"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
5222 f
"{format_picgrid_coordinate(z)}\n"
5225 if total_nodes_seen != total_nodes_expected:
5227 f
"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
5230 return {
"nblk": nblk,
"dims": dims,
"total_nodes": total_nodes_expected}
5234 @brief Read only the canonical PICGRID header dimensions.
5235 @param[in] source_grid Input grid file path.
5236 @param[in] expected_nblk Optional expected block count.
5237 @return List of (IM, JM, KM) node-count tuples.
5238 @throws ValueError on malformed header.
5240 if not os.path.isfile(source_grid):
5241 raise ValueError(f
"Grid file not found: {source_grid}")
5243 with open(source_grid,
"r")
as fin:
5246 _, first_token = next(line_iter)
5247 except StopIteration:
5248 raise ValueError(f
"Grid file '{source_grid}' is empty.")
5249 if first_token !=
"PICGRID":
5250 raise ValueError(f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
5253 _, nblk_line = next(line_iter)
5254 nblk = int(nblk_line)
5255 except StopIteration:
5256 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
5258 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
5260 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
5261 if expected_nblk
is not None and nblk != expected_nblk:
5262 raise ValueError(f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
5265 for bi
in range(nblk):
5267 lineno, dim_line = next(line_iter)
5268 except StopIteration:
5269 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
5270 parts = dim_line.split()
5273 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
5276 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
5279 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
5281 if im <= 0
or jm <= 0
or km <= 0:
5283 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
5285 dims.append((im, jm, km))
5290 expected_dims: tuple =
None) -> dict:
5292 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
5293 @param[in] source_slice Input PICSLICE path.
5294 @param[in] dest_slice Output staged PICSLICE path.
5295 @param[in] U_ref Reference velocity for non-dimensionalization.
5296 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
5297 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
5298 @throws ValueError on malformed slice.
5301 raise ValueError(
"velocity_ref must be non-zero when processing PICSLICE speeds.")
5302 if not os.path.isfile(source_slice):
5303 raise ValueError(f
"PICSLICE file not found: {source_slice}")
5305 with open(source_slice,
"r")
as fin:
5308 _, first_token = next(line_iter)
5309 except StopIteration:
5310 raise ValueError(f
"PICSLICE file '{source_slice}' is empty.")
5311 if first_token !=
"PICSLICE":
5312 raise ValueError(f
"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
5315 _, frame_line = next(line_iter)
5316 frame_count = int(frame_line)
5317 except StopIteration:
5318 raise ValueError(f
"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
5320 raise ValueError(f
"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
5321 if frame_count != 1:
5323 f
"PICSLICE file '{source_slice}' has frame count {frame_count}; Phase 1 supports exactly 1."
5327 lineno, dim_line = next(line_iter)
5328 except StopIteration:
5329 raise ValueError(f
"PICSLICE file '{source_slice}' missing slice dimensions.")
5330 parts = dim_line.split()
5333 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
5336 n1, n2 = (int(parts[0]), int(parts[1]))
5339 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
5341 if n1 <= 0
or n2 <= 0:
5342 raise ValueError(f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
5343 if expected_dims
is not None and (n1, n2) != tuple(expected_dims):
5345 f
"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
5349 for lineno, value_line
in line_iter:
5350 parts = value_line.split()
5353 f
"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
5356 value = float(parts[0])
5358 raise ValueError(f
"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
5359 if not math.isfinite(value):
5360 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be finite.")
5362 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
5363 values.append(value)
5365 expected_count = n1 * n2
5366 if len(values) != expected_count:
5368 f
"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
5371 os.makedirs(os.path.dirname(dest_slice), exist_ok=
True)
5372 with open(dest_slice,
"w")
as fout:
5373 fout.write(
"PICSLICE\n")
5375 fout.write(f
"{n1} {n2}\n")
5376 for value
in values:
5377 fout.write(f
"{value / U_ref:.8e}\n")
5380 "frame_count": frame_count,
5382 "value_count": len(values),
5383 "min_speed": min(values)
if values
else 0.0,
5384 "max_speed": max(values)
if values
else 0.0,
5389 @brief Convert a BC face token into a filesystem-friendly artifact token.
5390 @param[in] face Canonical face token such as -Zeta.
5391 @return Filesystem-friendly face token.
5393 return face.replace(
"+",
"pos").replace(
"-",
"neg")
5396 default_to_config_dir: bool =
False) -> str:
5398 @brief Resolve a run artifact path with run-dir-relative defaults.
5399 @param[in] run_dir Run/precompute directory root.
5400 @param[in] configured_path Optional user-provided artifact path.
5401 @param[in] default_path Default path relative to run_dir.
5402 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
5403 @return Absolute artifact path.
5405 path = configured_path
if configured_path
else default_path
5406 if not isinstance(path, str)
or not path.strip():
5407 raise ValueError(
"generated profile output_file must be a non-empty path when provided.")
5409 if os.path.isabs(path):
5410 return os.path.abspath(path)
5411 if default_to_config_dir
and os.path.dirname(path) ==
"":
5412 path = os.path.join(
"config", path)
5413 return os.path.abspath(os.path.join(run_dir, path))
5417 @brief Resolve an optional generator script override or repository default.
5418 @param[in] configured_script Optional absolute or case-relative script path.
5419 @param[in] case_path Current case.yml path used to anchor relative overrides.
5420 @param[in] default_name Repository generator filename under GENERATORS_PATH.
5421 @return Absolute generator script path.
5423 if configured_script
is None:
5424 return os.path.join(GENERATORS_PATH, default_name)
5425 if not isinstance(configured_script, str)
or not configured_script.strip():
5426 raise ValueError(f
"Generator script override for {default_name} must be a non-empty path.")
5427 script = configured_script.strip()
5428 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
5433 @brief Validate square-duct Poiseuille generator parameters.
5434 @param[in] params Generator params mapping.
5435 @param[in] field_name Human-readable YAML field name for diagnostics.
5436 @return Normalized params.
5440 if not isinstance(params, dict):
5441 raise ValueError(f
"{field_name}.params must be a mapping when provided.")
5442 unknown = sorted(set(params.keys()) - {
"bulk_velocity",
"n_terms"})
5444 raise ValueError(f
"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
5445 bulk_velocity =
_to_float(params.get(
"bulk_velocity", 1.0), f
"{field_name}.params.bulk_velocity")
5446 if bulk_velocity <= 0.0:
5447 raise ValueError(f
"{field_name}.params.bulk_velocity must be positive.")
5449 n_terms = int(params.get(
"n_terms", 101))
5450 except (TypeError, ValueError):
5451 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
5452 if n_terms <= 0
or n_terms % 2 == 0:
5453 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
5454 return {
"bulk_velocity": bulk_velocity,
"n_terms": n_terms}
5456GENERATED_PROFILE_GENERATORS = {
"square_duct_poiseuille"}
5460 @brief Validate a prescribed_flow field_slice source block.
5461 @param[in] source Source mapping from case.yml.
5462 @param[in] field_name Human-readable YAML path for diagnostics.
5463 @return Normalized source mapping.
5476 unknown = sorted(set(source.keys()) - allowed)
5478 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
5479 field_file = source.get(
"field_file")
5480 grid_file = source.get(
"grid_file")
5481 if not isinstance(field_file, str)
or not field_file.strip():
5482 raise ValueError(f
"{field_name}.field_file must be a non-empty path.")
5483 if not isinstance(grid_file, str)
or not grid_file.strip():
5484 raise ValueError(f
"{field_name}.grid_file must be a non-empty path.")
5485 if source.get(
"source_case")
is None and source.get(
"velocity_scale")
is None:
5486 raise ValueError(f
"{field_name} requires source_case or velocity_scale.")
5489 "type":
"field_slice",
5490 "field_file": field_file.strip(),
5491 "grid_file": grid_file.strip(),
5494 if source.get(
"script")
is not None:
5495 script = source.get(
"script")
5496 if not isinstance(script, str)
or not script.strip():
5497 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
5498 normalized[
"script"] = script.strip()
5499 if source.get(
"source_case")
is not None:
5500 source_case = source.get(
"source_case")
5501 if not isinstance(source_case, str)
or not source_case.strip():
5502 raise ValueError(f
"{field_name}.source_case must be a non-empty path when provided.")
5503 normalized[
"source_case"] = source_case.strip()
5504 if source.get(
"velocity_scale")
is not None:
5505 velocity_scale =
_to_float(source.get(
"velocity_scale"), f
"{field_name}.velocity_scale")
5506 if velocity_scale <= 0.0:
5507 raise ValueError(f
"{field_name}.velocity_scale must be positive.")
5508 normalized[
"velocity_scale"] = velocity_scale
5509 if source.get(
"source_block")
is not None:
5511 source_block = int(source.get(
"source_block"))
5512 except (TypeError, ValueError):
5513 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
5514 if source_block < 0:
5515 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
5516 normalized[
"source_block"] = source_block
5517 if source.get(
"output_file")
is not None:
5518 output_file = source.get(
"output_file")
5519 if not isinstance(output_file, str)
or not output_file.strip():
5520 raise ValueError(f
"{field_name}.output_file must be a non-empty path when provided.")
5521 normalized[
"output_file"] = output_file.strip()
5526 @brief Validate the field_slice slice selector.
5527 @param[in] slice_cfg Slice selector mapping.
5528 @param[in] field_name Human-readable YAML path for diagnostics.
5529 @return Normalized selector mapping.
5531 if not isinstance(slice_cfg, dict):
5532 raise ValueError(f
"{field_name} must be a mapping.")
5533 orientation = str(slice_cfg.get(
"orientation",
"opposite")).strip().lower()
5534 if orientation
not in {
"opposite",
"same"}:
5535 raise ValueError(f
"{field_name}.orientation must be 'opposite' or 'same'.")
5536 normal_tolerance =
_to_float(slice_cfg.get(
"normal_tolerance", 0.99), f
"{field_name}.normal_tolerance")
5537 if normal_tolerance <= 0.0
or normal_tolerance > 1.0:
5538 raise ValueError(f
"{field_name}.normal_tolerance must be in the range (0, 1].")
5540 if slice_cfg.get(
"face")
is not None:
5541 unknown = sorted(set(slice_cfg.keys()) - {
"face",
"orientation",
"normal_tolerance"})
5544 f
"Unknown keys in {field_name}: {unknown}. "
5545 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
5547 face = str(slice_cfg.get(
"face",
"")).strip()
5548 if face.lower()
not in BC_FACE_MAP:
5549 raise ValueError(f
"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
5551 "face": BC_FACE_MAP[face.lower()],
5552 "orientation": orientation,
5553 "normal_tolerance": normal_tolerance,
5556 required = {
"axis",
"index",
"normal"}
5557 missing = sorted(key
for key
in required
if slice_cfg.get(key)
is None)
5559 raise ValueError(f
"{field_name} requires either face or axis/index/normal; missing {missing}.")
5560 unknown = sorted(set(slice_cfg.keys()) - {
"axis",
"index",
"normal",
"orientation",
"normal_tolerance"})
5563 f
"Unknown keys in {field_name}: {unknown}. "
5564 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
5566 axis = str(slice_cfg.get(
"axis",
"")).strip()
5567 axis_map = {
"xi":
"Xi",
"eta":
"Eta",
"zeta":
"Zeta"}
5568 if axis.lower()
not in axis_map:
5569 raise ValueError(f
"{field_name}.axis must be one of Xi, Eta, Zeta.")
5570 normal = str(slice_cfg.get(
"normal",
"")).strip()
5571 if normal.lower()
not in BC_FACE_MAP:
5572 raise ValueError(f
"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
5573 normal = BC_FACE_MAP[normal.lower()]
5574 if normal[1:].lower() != axis.lower():
5575 raise ValueError(f
"{field_name}.normal must use the same axis as {field_name}.axis.")
5577 index = int(slice_cfg.get(
"index"))
5578 except (TypeError, ValueError):
5579 raise ValueError(f
"{field_name}.index must be an integer.")
5581 raise ValueError(f
"{field_name}.index must be non-negative.")
5583 "axis": axis_map[axis.lower()],
5586 "orientation": orientation,
5587 "normal_tolerance": normal_tolerance,
5591 target_grid: str =
None, target_block: int = 0,
5592 target_face: str =
None, script: str =
None,
5593 case_path: str =
None) -> dict:
5595 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
5596 @param[in] output_path Path to write.
5597 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
5598 @param[in] params Normalized generator params.
5599 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
5600 @param[in] target_block Target block index when `target_grid` is provided.
5601 @param[in] target_face Target inlet face when `target_grid` is provided.
5602 @param[in] script Optional profile.gen-compatible script override.
5603 @param[in] case_path Current case.yml path used to anchor relative script overrides.
5604 @return Summary dictionary.
5606 n1, n2 = tuple(dims)
5608 if not os.path.isfile(profilegen_script):
5609 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
5613 "square_duct_poiseuille",
5620 str(float(params[
"bulk_velocity"])),
5622 str(int(params[
"n_terms"])),
5629 str(int(target_block)),
5630 f
"--target-face={target_face}",
5632 result = subprocess.run(cmd, text=
True, capture_output=
True)
5633 if result.returncode != 0:
5634 details = (result.stderr
or result.stdout
or "").strip()
5635 raise ValueError(f
"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
5637 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
5638 except (IndexError, json.JSONDecodeError)
as exc:
5639 raise ValueError(f
"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
5640 summary[
"dims"] = tuple(summary[
"dims"])
5644 target_grid: str, target_face: str, target_block: int,
5645 case_path: str) -> dict:
5647 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
5648 @param[in] output_path Path to write.
5649 @param[in] expected_dims Expected PICSLICE dimensions.
5650 @param[in] source Normalized field_slice source mapping.
5651 @param[in] target_grid Target canonical PICGRID path.
5652 @param[in] target_face Target inlet face token.
5653 @param[in] target_block Target block index.
5654 @param[in] case_path Path to current case.yml for relative source resolution.
5655 @return Summary dictionary from profile.gen.
5657 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
5662 if not os.path.isfile(profilegen_script):
5663 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
5664 n1, n2 = tuple(expected_dims)
5665 slice_cfg = source[
"slice"]
5679 str(int(source.get(
"source_block", 0))),
5681 str(int(target_block)),
5682 f
"--target-face={target_face}",
5684 slice_cfg[
"orientation"],
5685 "--normal-tolerance",
5686 str(float(slice_cfg[
"normal_tolerance"])),
5688 str(float(velocity_scale)),
5693 if "face" in slice_cfg:
5694 cmd.append(f
"--slice-face={slice_cfg['face']}")
5700 str(int(slice_cfg[
"index"])),
5701 f
"--slice-normal={slice_cfg['normal']}",
5703 result = subprocess.run(cmd, text=
True, capture_output=
True)
5704 if result.returncode != 0:
5705 details = (result.stderr
or result.stdout
or "").strip()
5706 raise ValueError(f
"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
5708 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
5709 except (IndexError, json.JSONDecodeError)
as exc:
5710 raise ValueError(f
"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
5711 summary[
"dims"] = tuple(summary[
"dims"])
5716 @brief Resolve a path relative to the current case directory.
5717 @param[in] path_value Path from case.yml.
5718 @param[in] case_dir Current case directory.
5719 @return Absolute path.
5721 if not isinstance(path_value, str)
or not path_value.strip():
5722 raise ValueError(
"path value must be a non-empty string.")
5725 if os.path.isabs(path_value):
5727 f
"absolute path {path_value!r} is not allowed in workspace case configuration; "
5728 "use 'picurv inputs import --mode reference' for an explicit external reference."
5730 resolved = os.path.abspath(os.path.join(workspace_root, path_value))
5731 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
5732 raise ValueError(f
"path {path_value!r} escapes the workspace.")
5733 if resolved.endswith(
".reference.yml")
and os.path.isfile(resolved):
5735 external = pointer.get(
"picurv_external_reference")
if isinstance(pointer, dict)
else None
5736 if not isinstance(external, str)
or not os.path.isabs(external):
5737 raise ValueError(f
"Invalid external-reference descriptor: {resolved}")
5738 if not os.path.isfile(external):
5739 raise ValueError(f
"Registered external input is unavailable: {external}")
5742 if os.path.isabs(path_value):
5743 return os.path.abspath(path_value)
5744 return os.path.abspath(os.path.join(case_dir, path_value))
5748 @brief Resolve field_slice dimensional velocity scale.
5749 @param[in] source Normalized field_slice source mapping.
5750 @param[in] case_dir Current case directory.
5751 @return Positive velocity scale.
5753 if source.get(
"velocity_scale")
is not None:
5754 return float(source[
"velocity_scale"])
5759 source_case_cfg.get(
"properties", {}).get(
"scaling", {}).get(
"velocity_ref"),
5760 "source_case.properties.scaling.velocity_ref",
5762 except AttributeError
as exc:
5763 raise ValueError(
"source_case must contain properties.scaling.velocity_ref.")
from exc
5764 if velocity_scale <= 0.0:
5765 raise ValueError(
"source_case.properties.scaling.velocity_ref must be positive.")
5766 return velocity_scale
5770 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
5771 @param[in] case_cfg Parsed current case config.
5772 @param[in] case_path Current case.yml path.
5773 @param[in] run_dir Current run/precompute directory.
5774 @return Absolute target PICGRID path.
5776 grid_cfg = case_cfg.get(
"grid", {})
or {}
5777 grid_mode = grid_cfg.get(
"mode")
5778 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
5779 if grid_mode ==
"file":
5780 source_grid = grid_cfg.get(
"source_file")
5781 if not isinstance(source_grid, str)
or not source_grid.strip():
5782 raise ValueError(
"grid.source_file is required for field_slice target-grid normals.")
5785 if grid_mode ==
"grid_gen":
5786 candidate = os.path.abspath(os.path.join(run_dir,
"inputs",
"grid",
"grid.generated.picgrid"))
5787 if os.path.isfile(candidate):
5789 staged = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
5790 if os.path.isfile(staged):
5792 raise ValueError(
"field_slice requires the generated target PICGRID to exist before profile extraction.")
5794 f
"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
5799 @brief Resolve an optional target canonical PICGRID for generated profile sampling.
5800 @param[in] case_cfg Parsed current case config.
5801 @param[in] case_path Current case.yml path.
5802 @param[in] run_dir Current run/precompute directory.
5803 @return Absolute target PICGRID path, or None when no canonical grid is available yet.
5805 grid_mode = (case_cfg.get(
"grid", {})
or {}).get(
"mode")
5806 if grid_mode ==
"programmatic_c":
5812 @brief Write a profile.info summary for generated inlet profiles.
5813 @param[in] config_dir Run/precompute config directory.
5814 @param[in] summaries Generated profile summaries.
5815 @return Path to profile.info.
5817 info_path = os.path.join(config_dir,
"profile.info")
5818 os.makedirs(config_dir, exist_ok=
True)
5819 with open(info_path,
"w")
as fout:
5820 fout.write(
"# PICurv generated profile summary\n")
5821 fout.write(f
"profile_count = {len(summaries)}\n\n")
5822 for idx, summary
in enumerate(summaries):
5823 dims = summary.get(
"dims", (0, 0))
5824 fout.write(f
"[profile_{idx}]\n")
5825 fout.write(f
"generator = {summary.get('generator')}\n")
5826 fout.write(f
"block = {summary.get('block')}\n")
5827 fout.write(f
"face = {summary.get('face')}\n")
5828 fout.write(f
"dimensions = {dims[0]} {dims[1]}\n")
5829 if summary.get(
"bulk_velocity")
is not None:
5830 fout.write(f
"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
5831 if summary.get(
"n_terms")
is not None:
5832 fout.write(f
"n_terms = {summary.get('n_terms')}\n")
5833 fout.write(f
"mean_speed = {summary.get('mean_speed'):.16e}\n")
5834 if "area_mean_speed" in summary:
5835 fout.write(f
"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
5836 if "discrete_mean_speed" in summary:
5837 fout.write(f
"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
5838 fout.write(f
"min_speed = {summary.get('min_speed'):.16e}\n")
5839 fout.write(f
"max_speed = {summary.get('max_speed'):.16e}\n")
5840 if summary.get(
"umax_over_ubulk")
is not None:
5841 fout.write(f
"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
5845 "area_weighted_mean_before_normalization",
5846 "area_weighted_mean_after_normalization",
5863 fout.write(f
"{key} = {summary.get(key)}\n")
5864 fout.write(f
"output_file = {summary.get('path')}\n\n")
5869GRID_GENERATOR_CHOICE_FLAGS = {
5870 "--cross-section": GRID_CROSS_SECTION_KINDS,
5874GRID_GENERATOR_SPEC_FLAGS = {
5875 "--wall-j-lo": GRID_WALL_SEGMENT_KINDS,
5876 "--wall-j-hi": GRID_WALL_SEGMENT_KINDS,
5877 "--wall-j-lo-span": GRID_WALL_SEGMENT_KINDS,
5878 "--wall-j-hi-span": GRID_WALL_SEGMENT_KINDS,
5879 "--cross-section-scale": GRID_WALL_SEGMENT_KINDS,
5880 "--path": GRID_PATH_SEGMENT_KINDS,
5881 "--transforms": GRID_TRANSFORM_KINDS,
5887 @brief Check closed-choice values inside the generator's opaque token list.
5888 @details `cli_args` is passed through to grid.gen untouched, so a misspelled geometry
5889 selector there is only discovered when the subprocess exits nonzero partway
5890 into a run. The generator remains the authority; this reports the same set
5891 earlier, and stays silent on anything it does not recognize.
5892 @param[in] cli_args Raw token list from grid.generator.cli_args.
5893 @param[in] case_path Case file path for diagnostics.
5894 @return List of error strings.
5896 if not isinstance(cli_args, list):
5898 tokens = [str(token)
for token
in cli_args]
5900 for index, token
in enumerate(tokens):
5902 for follower
in tokens[index + 1:]:
5903 if follower.startswith(
"--"):
5905 values.append(follower)
5906 if token
in GRID_GENERATOR_CHOICE_FLAGS:
5907 allowed = GRID_GENERATOR_CHOICE_FLAGS[token]
5908 for value
in values[:1]:
5909 if value
not in allowed:
5911 f
" {case_path}: grid.generator.cli_args {token} must be one of "
5912 f
"{list(allowed)} (got '{value}')."
5914 elif token
in GRID_GENERATOR_SPEC_FLAGS:
5915 allowed = GRID_GENERATOR_SPEC_FLAGS[token]
5916 for value
in values:
5917 kind = value.split(
":", 1)[0]
5918 if kind
not in allowed:
5920 f
" {case_path}: grid.generator.cli_args {token} entry '{value}' "
5921 f
"names '{kind}', which is not one of {list(allowed)}."
5927 case_cfg: dict =
None) -> str:
5929 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
5930 @param[in] case_path Path to case.yml (used for relative path resolution).
5931 @param[in] run_dir Run directory path.
5932 @param[in] grid_cfg The grid config section from case.yml.
5933 @param[in] case_cfg Parsed case configuration, when available. Its reference scales
5934 are handed to the generator so the quality report can carry
5935 solver and wall units. Reporting only: the generator never scales
5936 coordinates, which validate_and_nondimensionalize_picgrid does
5937 once for every grid regardless of origin.
5938 @return Absolute path to generated dimensional PICGRID file.
5939 @throws ValueError on invalid config or generator failure.
5941 generator = grid_cfg.get(
"generator", {})
5942 if not isinstance(generator, dict):
5943 raise ValueError(
"grid.generator must be a mapping when grid.mode is 'grid_gen'.")
5945 case_dir = os.path.dirname(os.path.abspath(case_path))
5946 gridgen_script = generator.get(
"script", os.path.join(GENERATORS_PATH,
"grid.gen"))
5947 if generator.get(
"script"):
5950 gridgen_script = os.path.abspath(gridgen_script)
5951 if not os.path.isfile(gridgen_script):
5952 raise ValueError(f
"grid.gen script not found: {gridgen_script}")
5954 config_file = generator.get(
"config_file")
5956 raise ValueError(
"grid.generator.config_file is required when grid.mode is 'grid_gen'.")
5958 if not os.path.isfile(config_file):
5959 raise ValueError(f
"grid.generator.config_file not found: {config_file}")
5961 output_file = os.path.abspath(os.path.join(run_dir,
"inputs",
"grid",
"grid.generated.picgrid"))
5962 os.makedirs(os.path.dirname(output_file), exist_ok=
True)
5964 grid_type = generator.get(
"grid_type")
5965 cli_args = generator.get(
"cli_args", [])
5966 if cli_args
is None:
5968 if not isinstance(cli_args, list):
5969 raise ValueError(
"grid.generator.cli_args must be a list of CLI tokens.")
5971 cmd = [sys.executable, gridgen_script,
"-c", config_file]
5973 cmd.append(str(grid_type))
5974 cmd.extend([str(token)
for token
in cli_args])
5975 cmd.extend([
"--output", output_file])
5980 vts_file = os.path.abspath(
5981 os.path.join(run_dir,
"output",
"visualization",
"precompute",
"grid.vts")
5983 os.makedirs(os.path.dirname(vts_file), exist_ok=
True)
5984 cmd.extend([
"--vts", vts_file])
5986 stats_file = os.path.abspath(
5987 os.path.join(run_dir,
"output",
"analysis",
"metrics",
"grid.info")
5989 os.makedirs(os.path.dirname(stats_file), exist_ok=
True)
5990 cmd.extend([
"--stats-file", stats_file])
5998 except (KeyError, ValueError, TypeError):
6001 cmd.extend([
"--length-ref", repr(scaling[
"length_ref"]),
6002 "--velocity-ref", repr(scaling[
"velocity_ref"]),
6003 "--nu", repr(scaling[
"physical_kinematic_viscosity"])])
6005 print(f
"[INFO] Grid generator command: {' '.join(cmd)}")
6006 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
6007 if result.returncode != 0:
6008 stderr = (result.stderr
or "").strip()
6009 stdout = (result.stdout
or "").strip()
6010 details = stderr
if stderr
else stdout
6012 f
"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
6015 print(result.stdout.strip())
6017 print(result.stderr.strip())
6019 if not os.path.isfile(output_file):
6020 raise ValueError(f
"grid.gen did not produce expected output file: {output_file}")
6037 "symmetry":
"SYMMETRY",
6040 "periodic":
"PERIODIC",
6047 "required_params": set(),
6048 "optional_params": set(),
6050 "constant_velocity": {
6052 "required_params": {
"vx",
"vy",
"vz"},
6053 "optional_params": set(),
6056 "types": {
"OUTLET"},
6057 "required_params": set(),
6058 "optional_params": set(),
6062 "required_params": {
"v_max"},
6063 "optional_params": set(),
6065 "prescribed_flow": {
6067 "required_params": {
"source"},
6068 "optional_params": set(),
6071 "types": {
"PERIODIC"},
6072 "required_params": set(),
6073 "optional_params": set(),
6076 "types": {
"PERIODIC"},
6077 "required_params": {
"target_flux"},
6078 "optional_params": {
"enforce_seam_flux",
"apply_trim"},
6082 "types": {
"PERIODIC"},
6083 "required_params": set(),
6084 "optional_params": {
"enforce_seam_flux",
"apply_trim"},
6088_NUMERIC_BC_PARAMS = {
"vx",
"vy",
"vz",
"v_max",
"target_flux"}
6089_BOOL_BC_PARAMS = {
"enforce_seam_flux",
"apply_trim"}
6095_DEPRECATED_BC_PARAM_ALIASES = {
"apply_trim":
"enforce_seam_flux"}
6099 @brief Validate the structured source block for prescribed_flow BCs.
6100 @param[in] source Source mapping from case.yml.
6101 @param[in] field_name Human-readable YAML path for diagnostics.
6102 @return Normalized source mapping.
6103 @throws ValueError on invalid source contract.
6105 if not isinstance(source, dict):
6106 raise ValueError(f
"{field_name} must be a mapping with type: file, generated, or field_slice.")
6107 source_type = str(source.get(
"type",
"")).strip().lower()
6108 if source_type ==
"file":
6109 path = source.get(
"path")
6110 if not isinstance(path, str)
or not path.strip():
6111 raise ValueError(f
"{field_name}.path must be a non-empty file path.")
6112 unknown = sorted(set(source.keys()) - {
"type",
"path"})
6114 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
6115 return {
"type":
"file",
"path": path.strip()}
6117 if source_type ==
"generated":
6118 generator = str(source.get(
"generator",
"")).strip().lower()
6119 if generator
not in GENERATED_PROFILE_GENERATORS:
6121 f
"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
6122 f
"(got '{source.get('generator')}')."
6124 unknown = sorted(set(source.keys()) - {
"type",
"generator",
"script",
"output_file",
"params"})
6127 f
"Unknown keys in {field_name}: {unknown}. "
6128 "Allowed: ['generator', 'output_file', 'params', 'script', 'type']."
6131 "type":
"generated",
6132 "generator": generator,
6135 output_file = source.get(
"output_file")
6136 if output_file
is not None:
6137 if not isinstance(output_file, str)
or not output_file.strip():
6138 raise ValueError(f
"{field_name}.output_file must be a non-empty path when provided.")
6139 normalized[
"output_file"] = output_file.strip()
6140 script = source.get(
"script")
6141 if script
is not None:
6142 if not isinstance(script, str)
or not script.strip():
6143 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
6144 normalized[
"script"] = script.strip()
6147 if source_type ==
"field_slice":
6150 raise ValueError(f
"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
6154 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
6155 @param[in] face Canonical BC face token.
6156 @param[in] block_dims (IM, JM, KM) node counts.
6157 @return (n1, n2) dimensions in profile storage order.
6159 im, jm, km = block_dims
6160 if min(im, jm, km) < 2:
6162 f
"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
6164 if face
in {
"-Xi",
"+Xi"}:
6165 return (km - 1, jm - 1)
6166 if face
in {
"-Eta",
"+Eta"}:
6167 return (km - 1, im - 1)
6168 if face
in {
"-Zeta",
"+Zeta"}:
6169 return (jm - 1, im - 1)
6170 raise ValueError(f
"Unsupported face '{face}' for prescribed_flow profile dimensions.")
6174 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
6175 @param[in] case_cfg Parsed case.yml configuration.
6176 @param[in] case_path Path to case.yml for relative path resolution.
6177 @param[in] run_dir Current run directory, used for optional generated grid outputs.
6178 @return List of (IM, JM, KM) node-count tuples.
6179 @throws ValueError when dimensions cannot be resolved.
6181 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
6182 grid_cfg = case_cfg.get(
"grid", {})
6183 grid_mode = grid_cfg.get(
"mode")
6184 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
6186 if grid_mode ==
"programmatic_c":
6187 settings = grid_cfg.get(
"programmatic_settings", {})
6189 for key
in (
"im",
"jm",
"km"):
6190 raw = settings.get(key)
6192 raise ValueError(f
"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
6193 if isinstance(raw, list):
6194 if len(raw) != num_blocks:
6196 f
"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
6200 values = [raw] * num_blocks
6202 values = [int(v) + 1
for v
in values]
6203 except (TypeError, ValueError):
6204 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
6205 if any(v <= 1
for v
in values):
6206 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
6207 dims_by_axis.append(values)
6208 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
6210 if grid_mode ==
"file":
6211 source_grid = grid_cfg.get(
"source_file")
6212 if not isinstance(source_grid, str)
or not source_grid.strip():
6213 raise ValueError(
"grid.source_file is required for file-grid prescribed_flow profile validation.")
6217 if grid_mode ==
"grid_gen":
6220 candidates.append(os.path.join(run_dir,
"inputs",
"grid",
"grid.generated.picgrid"))
6221 candidates.append(os.path.join(run_dir,
"inputs",
"grid",
"grid.run"))
6222 for candidate
in candidates:
6223 if os.path.isfile(candidate):
6226 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
6227 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
6230 raise ValueError(f
"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
6233 profile_grid_dims: list =
None) -> list:
6235 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
6236 @param[in] run_dir Run/precompute directory root.
6237 @param[in] case_cfg Parsed case.yml.
6238 @param[in] case_path Path to case.yml for relative grid/source resolution.
6239 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
6240 @return List of generated profile summaries.
6244 bc.get(
"handler") ==
"prescribed_flow"
6245 and ((bc.get(
"params")
or {}).get(
"source")
or {}).get(
"type")
in PRESCRIBED_FLOW_SOURCE_TYPES[1:]
6246 for block
in prepared_blocks
for bc
in block
6249 if profile_grid_dims
is None:
6252 profile_dir = os.path.join(run_dir,
"inputs",
"inlet_profiles")
6254 generated_target_grid =
None
6256 for block_idx, block
in enumerate(prepared_blocks):
6258 if bc.get(
"handler") !=
"prescribed_flow":
6260 source = (bc.get(
"params")
or {}).get(
"source", {})
6261 if source.get(
"type")
not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
6266 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
6267 output_path = os.path.join(
6269 f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice",
6271 if source.get(
"type") ==
"generated" and source[
"generator"] ==
"square_duct_poiseuille":
6272 if generated_target_grid
is None:
6278 target_grid=generated_target_grid,
6279 target_block=block_idx,
6281 script=source.get(
"script"),
6282 case_path=case_path,
6284 elif source.get(
"type") ==
"generated":
6285 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
6287 if target_grid
is None:
6298 summary.update({
"block": block_idx,
"face": face})
6299 summaries.append(summary)
6301 f
"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
6302 f
"{os.path.relpath(output_path)} dims={summary['dims']}"
6307 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
6312 @brief Convert a YAML scalar to float with a clear error message.
6313 @param[in] value Argument passed to `_to_float()`.
6314 @param[in] field_name Argument passed to `_to_float()`.
6315 @return Value returned by `_to_float()`.
6319 except (TypeError, ValueError):
6320 raise ValueError(f
"'{field_name}' must be numeric (got {value!r}).")
6325 @brief Convert a non-boolean YAML scalar to a finite float.
6326 @param[in] value Raw YAML scalar.
6327 @param[in] field_name User-facing configuration path.
6328 @return Finite floating-point value.
6330 if isinstance(value, bool):
6331 raise ValueError(f
"'{field_name}' must be numeric, not boolean.")
6333 if not math.isfinite(parsed):
6334 raise ValueError(f
"'{field_name}' must be finite (got {value!r}).")
6339 @brief Convert a YAML scalar/string to bool with a clear error message.
6340 @param[in] value Argument passed to `_to_bool()`.
6341 @param[in] field_name Argument passed to `_to_bool()`.
6342 @return Value returned by `_to_bool()`.
6344 if isinstance(value, bool):
6346 if isinstance(value, str):
6347 raw = value.strip().lower()
6348 if raw
in {
"true",
"1",
"yes"}:
6350 if raw
in {
"false",
"0",
"no"}:
6352 raise ValueError(f
"'{field_name}' must be boolean (got {value!r}).")
6356 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
6357 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
6358 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
6359 @return Value returned by `normalize_boundary_conditions_layout()`.
6361 if not all_blocks_bcs:
6362 raise ValueError(
"The 'boundary_conditions' section in case.yml is empty.")
6364 is_simple_list = isinstance(all_blocks_bcs[0], dict)
6365 if num_blocks == 1
and is_simple_list:
6366 all_blocks_bcs = [all_blocks_bcs]
6367 elif is_simple_list
and num_blocks > 1:
6369 f
"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
6370 "Use a list-of-lists, one inner list per block."
6373 if len(all_blocks_bcs) != num_blocks:
6375 f
"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
6377 return all_blocks_bcs
6381 @brief Reports which logical axes a case declares periodic on both faces.
6382 @param[in] case_cfg Parsed case.yml mapping.
6383 @return Set of axis letters drawn from {'i', 'j', 'k'}.
6385 axis_faces = {
"i": (
"-Xi",
"+Xi"),
"j": (
"-Eta",
"+Eta"),
"k": (
"-Zeta",
"+Zeta")}
6387 entries = case_cfg.get(
"boundary_conditions")
or []
6388 if entries
and isinstance(entries[0], list):
6389 entries = entries[0]
6390 for entry
in entries:
6391 if isinstance(entry, dict):
6392 declared[str(entry.get(
"face"))] = str(entry.get(
"type",
"")).upper()
6394 axis
for axis, faces
in axis_faces.items()
6395 if all(declared.get(face) ==
"PERIODIC" for face
in faces)
6400 case_path: str, errors: list, warnings: list):
6402 @brief Rejects wall-model selections that no turbulence treatment can support.
6404 A wall model replaces the near-wall flow with an analytic profile so that the
6405 boundary layer need not be resolved. That only means something if the unresolved
6406 motions are modelled somewhere. Three combinations cannot be, and each fails here
6407 rather than after the mesh is built.
6409 @param case_cfg Parsed case configuration, read for the Reynolds number.
6410 @param les_cfg `models.physics.turbulence.les`, or None.
6411 @param rans_cfg `models.physics.turbulence.rans`, or None.
6412 @param wall_cfg `models.physics.turbulence.wall_function`, or None.
6413 @param case_path Case path, for message prefixes.
6414 @param errors Collected blocking messages, appended to.
6415 @param warnings Collected advisory messages, appended to.
6417 if not isinstance(wall_cfg, dict):
6419 if not bool(wall_cfg.get(
'enabled',
False)):
6427 les_on = isinstance(les_cfg, dict)
and bool(les_cfg.get(
'enabled',
False)) \
6428 and str(les_cfg.get(
'model',
'dynamic_smagorinsky')).strip().lower() !=
'none'
6429 rans_on = isinstance(rans_cfg, dict)
and bool(rans_cfg.get(
'enabled',
False))
6435 if not les_on
and not rans_on:
6437 f
" {case_path}: models.physics.turbulence.wall_function is enabled with no "
6438 "turbulence model. A wall model supplies the stress of a boundary layer it "
6439 "does not resolve, which needs the unresolved motions modelled somewhere. "
6440 "This solver has no implicit-LES scheme to supply that - its convection is "
6441 "QUICK, whose numerical dissipation is not a subgrid model - so enable "
6442 "models.physics.turbulence.les, or resolve the wall and disable the wall "
6450 if rans_on
and model == 3:
6452 f
" {case_path}: models.physics.turbulence.wall_function.model 'cabot' cannot "
6453 "be used with RANS. Cabot solves the wall layer with its own mixing-length "
6454 "eddy viscosity, so under a RANS model the near-wall layer would carry two "
6455 "turbulence closures with no matching between them. Use 'log_law' with RANS.")
6456 if rans_on
and model == 2:
6458 f
" {case_path}: models.physics.turbulence.wall_function.model 'werner' cannot "
6459 "be used with RANS. Werner-Wengle applies its power law to the instantaneous "
6460 "filtered velocity, which is a large-eddy quantity; a RANS field is already "
6461 "averaged and wants a wall law derived for the mean profile. Use 'log_law' "
6469 if reynolds
is not None and reynolds < 1000.0:
6471 f
" {case_path}: models.physics.turbulence.wall_function is enabled at "
6472 f
"Reynolds number {reynolds:g}, which is laminar. The log law and the "
6473 "Werner-Wengle power law both describe a turbulent boundary layer; at this "
6474 "Reynolds number there is no inertial region for either to represent, and "
6475 "the model would impose a profile the flow does not have. Resolve the wall "
6481 @brief Reynolds number implied by a case's scaling and fluid properties.
6482 @param case_cfg Parsed case configuration.
6483 @return The Reynolds number, or None when the inputs are absent or unusable.
6486 props = case_cfg.get(
'properties', {})
or {}
6487 scaling = props.get(
'scaling', {})
or {}
6488 fluid = props.get(
'fluid', {})
or {}
6489 density = float(fluid.get(
'density'))
6490 viscosity = float(fluid.get(
'viscosity'))
6491 length_ref = float(scaling.get(
'length_ref'))
6492 velocity_ref = float(scaling.get(
'velocity_ref'))
6493 except (TypeError, ValueError):
6495 if viscosity <= 0.0:
6497 return density * velocity_ref * length_ref / viscosity
6501 errors: list, warnings: list):
6503 @brief Checks the structured LES block for values the closure cannot honour.
6504 @param[in] case_cfg Parsed case.yml mapping, used to read declared periodicity.
6505 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
6506 @param[in] case_path Case file path used to prefix diagnostics.
6507 @param[out] errors List collecting blocking validation failures.
6508 @param[out] warnings List collecting advisory messages.
6509 @return None; findings are appended to `errors` and `warnings`.
6511 def _numeric(container, key, path, minimum=None, exclusive_minimum=None):
6513 @brief Reads one numeric key and records a range or type failure against it.
6514 @param[in] container Mapping holding the key.
6515 @param[in] key Key to read; absent keys are accepted silently.
6516 @param[in] path Dotted key path used in diagnostics.
6517 @param[in] minimum Inclusive lower bound, or None to impose none.
6518 @param[in] exclusive_minimum Exclusive lower bound, or None to impose none.
6519 @return The parsed value, or None when the key is absent or unparseable.
6521 if key
not in container:
6524 value = float(container[key])
6525 except (TypeError, ValueError):
6526 errors.append(f
" {case_path}: {path} must be numeric.")
6528 if minimum
is not None and value < minimum:
6529 errors.append(f
" {case_path}: {path} must be at least {minimum}.")
6530 if exclusive_minimum
is not None and value <= exclusive_minimum:
6531 errors.append(f
" {case_path}: {path} must be greater than {exclusive_minimum}.")
6534 if 'enabled' in les_cfg
and not isinstance(les_cfg[
'enabled'], bool):
6535 errors.append(f
" {case_path}: models.physics.turbulence.les.enabled must be true or false.")
6537 _numeric(les_cfg,
'constant_cs',
"models.physics.turbulence.les.constant_cs", minimum=0.0)
6539 if 'dynamic_frequency' in les_cfg:
6541 if int(les_cfg[
'dynamic_frequency']) <= 0:
6542 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
6543 except (TypeError, ValueError):
6544 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
6546 for key, normalizer
in ((
'filter_width', normalize_les_filter_width),):
6549 normalizer(les_cfg[key])
6550 except ValueError
as exc:
6551 errors.append(f
" {case_path}: {exc}")
6555 test_filter = les_cfg.get(
'test_filter')
6556 if test_filter
is not None:
6557 if not isinstance(test_filter, dict):
6558 test_filter = {
'kernel': test_filter}
6559 if 'kernel' in test_filter:
6562 except ValueError
as exc:
6563 errors.append(f
" {case_path}: {exc}")
6567 if kernel == 1
and not {
"i",
"k"} <= periodic:
6569 f
" {case_path}: models.physics.turbulence.les.test_filter.kernel "
6570 "'simpson_ik' assumes the xi and zeta directions are homogeneous, but "
6571 "this case does not declare both of them PERIODIC. Use "
6572 "'volume_weighted_box' instead."
6576 _numeric(test_filter,
'width_ratio',
6577 "models.physics.turbulence.les.test_filter.width_ratio", exclusive_minimum=1.0)
6579 averaging = les_cfg.get(
'averaging')
6580 if averaging
is not None:
6581 if not isinstance(averaging, dict):
6582 averaging = {
'mode': averaging}
6584 if 'mode' in averaging:
6587 except ValueError
as exc:
6588 errors.append(f
" {case_path}: {exc}")
6590 if 'directions' in averaging:
6593 except ValueError
as exc:
6594 errors.append(f
" {case_path}: {exc}")
6598 f
" {case_path}: models.physics.turbulence.les.averaging.directions "
6599 "cannot be empty; omit the key to use the periodic axes."
6601 if mode
is not None and mode != 1:
6603 f
" {case_path}: models.physics.turbulence.les.averaging.directions "
6604 "applies only to mode 'homogeneous'; local and global averaging choose "
6605 "their own directions."
6607 for axis
in directions:
6608 if axis
not in periodic:
6610 f
"{case_path}: models.physics.turbulence.les.averaging.directions "
6611 f
"names '{axis}', which this case does not declare PERIODIC. "
6612 "Averaging assumes the flow is statistically homogeneous there."
6614 if mode == 1
and directions
is None and not periodic:
6616 f
" {case_path}: models.physics.turbulence.les.averaging.mode 'homogeneous' "
6617 "derives its directions from the periodic boundary pairs, and this case declares "
6618 "none. Name the directions explicitly or use 'local'."
6621 clipping = les_cfg.get(
'clipping')
6622 if clipping
is not None:
6623 if not isinstance(clipping, dict):
6624 clipping = {
'mode': clipping}
6626 if 'mode' in clipping:
6629 except ValueError
as exc:
6630 errors.append(f
" {case_path}: {exc}")
6631 _numeric(clipping,
'max_cs',
"models.physics.turbulence.les.clipping.max_cs", minimum=0.0)
6632 _numeric(clipping,
'min_viscosity_ratio',
6633 "models.physics.turbulence.les.clipping.min_viscosity_ratio", minimum=0.0)
6634 if 'max_cs' in clipping
and mode
is not None and mode != 0:
6636 f
" {case_path}: models.physics.turbulence.les.clipping.max_cs applies only to "
6637 "mode 'clamp'; 'positive' and 'signed' impose no upper bound. Remove the key or "
6638 "select mode 'clamp'."
6641 diagnostics = les_cfg.get(
'diagnostics')
6642 if isinstance(diagnostics, dict):
6643 if 'cadence' in diagnostics:
6645 if int(diagnostics[
'cadence']) <= 0:
6647 f
" {case_path}: models.physics.turbulence.les.diagnostics.cadence must be positive."
6649 except (TypeError, ValueError):
6651 f
" {case_path}: models.physics.turbulence.les.diagnostics.cadence must be an integer."
6653 _numeric(diagnostics,
'yoshizawa_ci',
6654 "models.physics.turbulence.les.diagnostics.yoshizawa_ci", minimum=0.0)
6663 if model_code == 1
and les_cfg.get(
'enabled',
True):
6664 for key
in (
'filter_width',
'test_filter',
'averaging',
'clipping'):
6667 f
" {case_path}: models.physics.turbulence.les.{key} configures the dynamic "
6668 "procedure and cannot be used with model 'constant_smagorinsky'."
6674 @brief Validate BC entries against currently supported C-side handlers/types and
6675 @details return normalized entries ready for bcs.run generation.
6676 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
6677 @return Value returned by `validate_and_prepare_boundary_conditions()`.
6679 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
6680 scales = case_cfg.get(
'properties', {}).get(
'scaling', {})
6681 L_ref =
_to_float(scales.get(
'length_ref'),
"properties.scaling.length_ref")
6682 U_ref =
_to_float(scales.get(
'velocity_ref'),
"properties.scaling.velocity_ref")
6684 raise ValueError(
"properties.scaling.velocity_ref must be non-zero for non-dimensionalization.")
6686 raise ValueError(
"properties.scaling.length_ref must be non-zero for non-dimensionalization.")
6689 prepared_blocks = []
6691 expected_faces = {
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta"}
6692 axis_pairs = [(
"-Xi",
"+Xi"), (
"-Eta",
"+Eta"), (
"-Zeta",
"+Zeta")]
6694 for bi, block_bcs
in enumerate(all_blocks_bcs):
6695 if not isinstance(block_bcs, list):
6696 raise ValueError(f
"boundary_conditions[{bi}] must be a list of face configs.")
6701 for idx, bc
in enumerate(block_bcs):
6702 if not isinstance(bc, dict):
6703 raise ValueError(f
"boundary_conditions[{bi}][{idx}] must be a mapping.")
6705 for req
in (
"face",
"type",
"handler"):
6707 raise ValueError(f
"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
6709 face_raw = str(bc[
"face"]).strip()
6710 face_key = face_raw.lower()
6711 face = BC_FACE_MAP.get(face_key)
6714 f
"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
6715 f
"Supported: {sorted(expected_faces)}."
6717 if face
in seen_faces:
6718 raise ValueError(f
"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
6719 seen_faces[face] = idx
6721 bc_type_raw = str(bc[
"type"]).strip()
6722 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
6725 f
"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
6726 f
"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
6729 handler = str(bc[
"handler"]).strip().lower()
6730 handler_spec = BC_HANDLER_SPECS.get(handler)
6731 if handler_spec
is None:
6733 f
"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
6734 f
"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
6736 if bc_type
not in handler_spec[
"types"]:
6738 f
"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
6741 params = bc.get(
"params", {})
6744 if not isinstance(params, dict):
6745 raise ValueError(f
"'params' for block {bi}, face {face} must be a mapping.")
6748 if "vector" in params
or "velocity" in params:
6750 f
"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
6751 "Use scalar keys 'vx', 'vy', 'vz'."
6754 required = handler_spec[
"required_params"]
6755 optional = handler_spec[
"optional_params"]
6756 allowed = required | optional
6758 missing = sorted(required - set(params.keys()))
6761 f
"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
6763 unknown = sorted(set(params.keys()) - allowed)
6766 f
"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
6767 f
"Allowed: {sorted(allowed)}."
6770 converted_params = {}
6771 for key, value
in params.items():
6772 if key
in _NUMERIC_BC_PARAMS:
6773 numeric =
_to_float(value, f
"boundary_conditions[{bi}][{idx}].params.{key}")
6774 if key
in {
"vx",
"vy",
"vz",
"v_max"}:
6775 converted_params[key] = numeric / U_ref
6776 elif key ==
"target_flux":
6777 converted_params[key] = numeric / (U_ref * (L_ref ** 2))
6778 elif key
in _BOOL_BC_PARAMS:
6779 canonical = _DEPRECATED_BC_PARAM_ALIASES.get(key, key)
6780 if canonical != key:
6781 if canonical
in params:
6783 f
"boundary_conditions[{bi}][{idx}].params sets both '{key}' and its "
6784 f
"replacement '{canonical}'. Use '{canonical}' only."
6787 f
"[WARNING] boundary_conditions[{bi}][{idx}].params.{key} is deprecated; "
6788 f
"use '{canonical}'. It enables the local seam-flux correction on the "
6789 "periodic boundary plane, on top of the body force that sustains the bulk "
6790 "flow. See docs/pages/54_Geometric_Periodic_Boundaries.md.",
6793 converted_params[canonical] =
_to_bool(
6794 value, f
"boundary_conditions[{bi}][{idx}].params.{key}")
6795 elif handler ==
"prescribed_flow" and key ==
"source":
6797 value, f
"boundary_conditions[{bi}][{idx}].params.source"
6801 converted_params[key] = value
6803 prepared_block.append({
6807 "params": converted_params,
6810 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
6813 f
"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
6814 "Provide all six faces explicitly."
6818 face_map = {entry[
"face"]: entry
for entry
in prepared_block}
6819 for neg_face, pos_face
in axis_pairs:
6820 neg = face_map[neg_face]
6821 pos = face_map[pos_face]
6822 neg_periodic = (neg[
"type"] ==
"PERIODIC")
6823 pos_periodic = (pos[
"type"] ==
"PERIODIC")
6824 if neg_periodic != pos_periodic:
6826 f
"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
6829 driven_handlers = {
"constant_flux",
"initial_flux"}
6830 if (neg[
"handler"]
in driven_handlers)
or (pos[
"handler"]
in driven_handlers):
6831 if neg[
"handler"] != pos[
"handler"]:
6833 f
"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
6835 if not (neg_periodic
and pos_periodic):
6837 f
"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
6840 prepared_blocks.append(prepared_block)
6842 return prepared_blocks
6847 @brief Render an internal schema path tuple as a user-facing YAML path.
6848 @param[in] path Internal path tuple.
6849 @return Dotted YAML path.
6851 return ".".join(part
for part
in path
if part !=
"[]")
or "<root>"
6856 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
6857 @param[in] schema Role schema mapping.
6858 @param[in] path Internal path tuple.
6859 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
6863 for idx, part
in enumerate(path):
6866 candidate = path[:idx] + (
"*",) + path[idx + 1:]
6867 if candidate
in schema:
6868 return schema[candidate]
6874 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
6875 @param[in] schema Role schema mapping.
6876 @param[in] path Current internal YAML path tuple.
6877 @param[in] key Unsupported YAML key.
6878 @param[in] allowed Allowed keys at the current path.
6879 @return Optional hint string.
6882 allowed_strings = sorted(str(item)
for item
in allowed)
6883 lower_matches = [item
for item
in allowed_strings
if item.lower() == key.lower()]
6884 close_matches = lower_matches
or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
6886 hints.append(f
"Did you mean '{close_matches[0]}'?")
6889 for schema_path, schema_allowed
in schema.items():
6890 if schema_path == path
or not schema_allowed:
6892 if key
in schema_allowed:
6895 hints.append(f
"This key is valid at: {', '.join(sorted(valid_paths))}.")
6897 return " ".join(hints)
6902 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
6903 @param[in] cfg Parsed YAML node.
6904 @param[in] schema Role schema mapping.
6905 @param[in] file_path Source file path for diagnostics.
6906 @param[in,out] errors Validation error accumulator.
6907 @param[in] path Current internal YAML path tuple.
6909 if isinstance(cfg, dict):
6911 if allowed
is not False and allowed
is not None:
6912 unknown = sorted(str(key)
for key
in cfg.keys()
if key
not in allowed)
6915 hint_text = f
" {hint}" if hint
else ""
6917 f
" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
6918 f
"Allowed keys: {sorted(allowed)}.{hint_text}"
6922 for key, value
in cfg.items():
6924 elif isinstance(cfg, list):
6931 "title",
"properties",
"run_control",
"grid",
"models",
"boundary_conditions",
"solver_parameters",
6933 (
"run_control",): {
"start_step",
"total_steps",
"dt_physical"},
6934 (
"properties",): {
"scaling",
"fluid",
"initial_conditions"},
6935 (
"properties",
"scaling"): {
"length_ref",
"velocity_ref"},
6936 (
"properties",
"fluid"): {
"density",
"viscosity"},
6937 (
"properties",
"initial_conditions"): {
6938 "mode",
"generator",
"params",
"field",
"source_file",
6939 "u_physical",
"v_physical",
"w_physical",
"peak_velocity_physical",
6940 "velocity_physical",
"flow_direction",
6942 (
"properties",
"initial_conditions",
"params"):
None,
6944 "mode",
"source_file",
"programmatic_settings",
"generator",
6945 "da_processors_x",
"da_processors_y",
"da_processors_z",
6947 (
"grid",
"programmatic_settings"): {
6948 "im",
"jm",
"km",
"xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
6949 "rxs",
"rys",
"rzs",
"cgrids",
6950 "da_processors_x",
"da_processors_y",
"da_processors_z",
6952 (
"grid",
"generator"): {
6953 "script",
"config_file",
"grid_type",
"cli_args",
"output_file",
"stats_file",
"vts_file",
6955 "config-file",
"grid-type",
"output-file",
"stats-file",
"vts-file",
6957 (
"models",): {
"domain",
"physics"},
6958 (
"models",
"domain"): {
"blocks"},
6959 (
"models",
"physics"): {
"dimensionality",
"fsi",
"particles",
"turbulence"},
6960 (
"models",
"physics",
"fsi"): {
"immersed",
"moving_fsi"},
6961 (
"models",
"physics",
"particles"): {
"count",
"init_mode",
"restart_mode",
"point_source"},
6962 (
"models",
"physics",
"particles",
"point_source"): {
"x",
"y",
"z"},
6963 (
"models",
"physics",
"turbulence"): {
"les",
"rans",
"wall_function"},
6964 (
"models",
"physics",
"turbulence",
"les"): {
6965 "enabled",
"model",
"constant_cs",
"dynamic_frequency",
"filter_width",
6966 "test_filter",
"averaging",
"clipping",
"gradient_model",
"diagnostics",
6968 (
"models",
"physics",
"turbulence",
"les",
"test_filter"): {
"kernel",
"width_ratio"},
6969 (
"models",
"physics",
"turbulence",
"les",
"averaging"): {
"mode",
"directions"},
6970 (
"models",
"physics",
"turbulence",
"les",
"clipping"): {
6971 "mode",
"max_cs",
"min_viscosity_ratio",
6973 (
"models",
"physics",
"turbulence",
"les",
"gradient_model"): {
"enabled"},
6974 (
"models",
"physics",
"turbulence",
"les",
"diagnostics"): {
6975 "enabled",
"cadence",
"yoshizawa_ci",
6977 (
"models",
"physics",
"turbulence",
"rans"): {
"enabled",
"model"},
6978 (
"models",
"physics",
"turbulence",
"wall_function"): {
"enabled",
"model",
"roughness_height"},
6979 (
"boundary_conditions",
"[]"): {
"face",
"type",
"handler",
"params"},
6980 (
"boundary_conditions",
"[]",
"[]"): {
"face",
"type",
"handler",
"params"},
6981 (
"boundary_conditions",
"[]",
"params"):
None,
6982 (
"boundary_conditions",
"[]",
"[]",
"params"):
None,
6983 (
"solver_parameters",):
None,
6989 "operation_mode",
"strategy",
"tolerances",
"momentum_solver",
"poisson_solver",
6990 "pressure_solver",
"interpolation",
"petsc_passthrough_options",
"verification",
6993 (
"operation_mode",): {
"eulerian_field_source",
"analytical_type",
"uniform_flow"},
6994 (
"operation_mode",
"uniform_flow"): {
"u",
"v",
"w"},
6995 (
"strategy",): {
"momentum_solver",
"central_diff"},
6997 "max_iterations",
"absolute_tol",
"relative_tol",
"step_tol",
6998 "residual_absolute_tol",
"residual_relative_tol",
7000 (
"momentum_solver",): {
7001 "type",
"dual_time_picard_jameson_rk",
"dual_time_picard_rk4",
"newton_krylov",
7003 (
"momentum_solver",
"dual_time_picard_jameson_rk"): {
7004 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
7005 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
7008 (
"momentum_solver",
"dual_time_picard_jameson_rk",
"pseudo_cfl"): {
7009 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
7011 (
"momentum_solver",
"dual_time_picard_rk4"): {
7012 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
7013 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
7016 (
"momentum_solver",
"dual_time_picard_rk4",
"pseudo_cfl"): {
7017 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
7019 (
"momentum_solver",
"newton_krylov"): {
7020 "jacobian",
"preconditioner",
"nonlinear_solver",
"linear_solver",
7022 (
"momentum_solver",
"newton_krylov",
"jacobian"): {
"type",
"finite_difference"},
7023 (
"momentum_solver",
"newton_krylov",
"jacobian",
"finite_difference"): {
"mode"},
7024 (
"momentum_solver",
"newton_krylov",
"preconditioner"): {
"model",
"structure"},
7025 (
"momentum_solver",
"newton_krylov",
"preconditioner",
"structure"): {
"type"},
7026 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver"): {
7027 "method",
"absolute_tolerance",
"relative_tolerance",
"step_tolerance",
7028 "max_iterations",
"line_search",
"eisenstat_walker",
7030 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver",
"line_search"): {
"type"},
7031 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver",
"eisenstat_walker"): {
7032 "enabled",
"version",
"initial_relative_tolerance",
"maximum_relative_tolerance",
7033 "gamma",
"exponent",
"safeguard_exponent",
"safeguard_threshold",
7035 (
"momentum_solver",
"newton_krylov",
"linear_solver"): {
7036 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
7037 "gmres",
"preconditioner",
7039 (
"momentum_solver",
"newton_krylov",
"linear_solver",
"gmres"): {
"restart"},
7040 (
"momentum_solver",
"newton_krylov",
"linear_solver",
"preconditioner"): {
"type"},
7041 (
"poisson_solver",): {
7042 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
7043 "gmres",
"preconditioner",
"multigrid",
7045 (
"pressure_solver",): {
7046 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
7047 "gmres",
"preconditioner",
"multigrid",
7049 (
"poisson_solver",
"gmres"): {
"restart"},
7050 (
"pressure_solver",
"gmres"): {
"restart"},
7051 (
"poisson_solver",
"preconditioner"): {
"type"},
7052 (
"pressure_solver",
"preconditioner"): {
"type"},
7053 (
"poisson_solver",
"multigrid"): {
7054 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
7056 (
"pressure_solver",
"multigrid"): {
7057 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
7059 (
"poisson_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
7060 (
"pressure_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
7061 (
"poisson_solver",
"multigrid",
"level_solvers",
"*"): {
7062 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
7064 (
"pressure_solver",
"multigrid",
"level_solvers",
"*"): {
7065 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
7067 (
"interpolation",): {
"method"},
7068 (
"petsc_passthrough_options",):
None,
7069 (
"verification",): {
"sources"},
7070 (
"verification",
"sources"): {
"diffusivity",
"scalar"},
7071 (
"verification",
"sources",
"diffusivity"): {
"mode",
"profile",
"gamma0",
"slope_x"},
7072 (
"verification",
"sources",
"scalar"): {
7073 "mode",
"profile",
"value",
"phi0",
"slope_x",
"amplitude",
"kx",
"ky",
"kz",
7075 (
"scalar_transport",): {
"schmidt_number",
"turbulent_schmidt_number"},
7081 "logging",
"profiling",
"diagnostics",
"io",
"solver_monitoring",
"solution_monitoring",
7084 (
"logging",): {
"verbosity",
"enabled_functions"},
7085 (
"profiling",): {
"timestep_output",
"final_summary"},
7086 (
"profiling",
"timestep_output"): {
"mode",
"functions",
"file"},
7087 (
"profiling",
"final_summary"): {
"enabled"},
7088 (
"diagnostics",): {
"petsc",
"runtime_memory_log"},
7089 (
"diagnostics",
"petsc"): {
7090 "info",
"malloc_debug",
"malloc_test",
"malloc_dump",
"malloc_view",
"malloc_view_threshold",
7091 "memory_view",
"log_view",
"log_view_memory",
"log_all",
"log_trace",
7092 "objects_dump",
"options_left",
7094 (
"diagnostics",
"petsc",
"info"): {
"enabled",
"classes"},
7095 (
"diagnostics",
"runtime_memory_log"): {
"enabled",
"file"},
7097 "data_output_frequency",
"particle_console_output_frequency",
"particle_log_interval",
7098 "statistics_console_output_frequency",
7100 (
"solver_monitoring",): {
"momentum",
"poisson",
"petsc_passthrough_options"},
7101 (
"solver_monitoring",
"momentum"): {
7102 "newton_krylov_history",
"snes_monitor",
"snes_converged_reason",
7103 "ksp_monitor",
"ksp_converged_reason",
7105 (
"solver_monitoring",
"poisson"): {
"pic_true_residual",
"true_residual",
"converged_reason",
"view"},
7106 (
"solver_monitoring",
"petsc_passthrough_options"):
None,
7107 (
"solution_monitoring",): {
"convergence"},
7108 (
"solution_monitoring",
"convergence"): {
7109 "enabled",
"mode",
"periodic_deterministic",
"statistical_steady",
7111 (
"solution_monitoring",
"convergence",
"periodic_deterministic"): {
"period_steps"},
7112 (
"solution_monitoring",
"convergence",
"statistical_steady"): {
"window_steps"},
7113 (
"field_statistics",): {
"enabled",
"windows"},
7114 (
"field_statistics",
"windows",
"[]"): {
7115 "name",
"start_time",
"end_time",
"weighting",
7116 "step_cadence",
"time_cadence",
"fields",
"covariances",
7118 (
"field_statistics",
"windows",
"[]",
"fields",
"[]"): {
"field",
"moments"},
7129STATISTICS_ELIGIBLE_FIELDS = {
7130 "Ucat": {
"components": 3,
"requires":
None},
7131 "P": {
"components": 1,
"requires":
None},
7132 "Nvert": {
"components": 1,
"requires":
None},
7133 "Phi": {
"components": 1,
"requires":
None},
7134 "Psi": {
"components": 1,
"requires":
"particles"},
7135 "ParticleCount": {
"components": 1,
"requires":
"particles"},
7136 "Nu_t": {
"components": 1,
"requires":
"turbulence"},
7137 "CS": {
"components": 1,
"requires":
"les"},
7143STATISTICS_MOMENT_NAMES = (
"first",
"second")
7146STATISTICS_WEIGHTING_MODES = (
"sample",
"physical_time")
7151 "run_control",
"source_data",
"global_operations",
"eulerian_pipeline",
7152 "lagrangian_pipeline",
"statistics_pipeline",
"statistics_output_prefix",
7153 "field_statistics",
"spectra",
"io",
7155 (
"field_statistics",): {
"windows",
"source_step",
"outputs",
"formats"},
7156 (
"spectra",): {
"output_prefix",
"tasks"},
7157 (
"spectra",
"tasks",
"[]"): {
7158 "task",
"field",
"block",
"symbol",
"subtract_mean",
"mean_source_step",
7162 "start_step",
"end_step",
"step_interval",
"startTime",
"endTime",
"timeStep",
7164 (
"source_data",): {
"directory",
"input_extensions"},
7165 (
"source_data",
"input_extensions"): {
"eulerian",
"particle"},
7166 (
"global_operations",): {
"dimensionalize"},
7167 (
"eulerian_pipeline",
"[]"): {
"task",
"input_field",
"output_field",
"field",
"reference_point"},
7168 (
"lagrangian_pipeline",
"[]"): {
"task",
"input_field",
"output_field"},
7169 (
"statistics_pipeline",): {
"output_prefix",
"tasks"},
7170 (
"statistics_pipeline",
"tasks",
"[]"): {
"task"},
7172 "output_directory",
"output_filename_prefix",
"particle_filename_prefix",
"output_particles",
7173 "particle_subsampling_frequency",
"input_extensions",
7174 "eulerian_fields",
"particle_fields",
7176 (
"io",
"input_extensions"): {
"eulerian",
"particle"},
7181 (): {
"scheduler",
"resources",
"notifications",
"execution"},
7182 (
"scheduler",): {
"type"},
7183 (
"resources",): {
"account",
"partition",
"nodes",
"ntasks_per_node",
"mem",
"time"},
7184 (
"notifications",): {
"mail_user",
"mail_type"},
7186 "module_setup",
"launcher",
"launcher_args",
"extra_sbatch",
"walltime_guard",
7188 (
"execution",
"extra_sbatch"):
None,
7189 (
"execution",
"walltime_guard"): {
7190 "enabled",
"warmup_steps",
"multiplier",
"min_seconds",
"estimator_alpha",
7197 "title",
"base_configs",
"study_type",
"parameters",
"parameter_sets",
"metrics",
"plotting",
"execution",
7199 (
"base_configs",): {
"case",
"solver",
"monitor",
"post"},
7200 (
"parameters",):
None,
7201 (
"parameter_sets",
"[]"):
None,
7202 (
"metrics",
"[]"): {
7203 "name",
"source",
"file_glob",
"column",
"reduction",
"normalize_by_parameter",
7204 "numerator_column",
"denominator_column",
"denominator_floor",
7205 "plot_label",
"label",
"units",
7207 (
"plotting",): {
"enabled",
"output_format"},
7208 (
"execution",): {
"max_concurrent_array_tasks"},
7212_WORKSPACE_SCHEMA = {
7213 (): {
"schema_version",
"workspace",
"software",
"paths",
"reproducibility"},
7214 (
"workspace",): {
"id",
"template",
"created_at"},
7215 (
"software",): {
"picurv"},
7216 (
"paths",): {
"config",
"inputs",
"assets",
"runs",
"studies"},
7217 (
"reproducibility",): {
"require_clean_release",
"pin_executables"},
7224RUN_OWNED_DIRECTORY_KEYS = (
"log",
"output")
7225UNSAFE_PATHS_OVERRIDE_KEY =
"allow_unsafe_paths"
7231RESERVED_RUN_DIRECTORY_NAMES = (
"config",
"scheduler",
"checkpoints",
"visualization")
7235RUN_DIRECTORY_DEFAULTS = {
"log":
"logs",
"output":
"output"}
7239RESERVED_DIRECTORY_FLAGS = (
7240 "-log_dir",
"-output_dir",
"-restart_dir",
"-analysis_dir",
7241 "-allow_unsafe_log_dir",
7247RESERVED_INDIRECTION_FLAGS = (
"-options_file",
"-options_file_yaml",
"-alias")
7254UNSAFE_DIRECTORY_CHARACTERS = (
'"',
"'",
"#",
"\n",
"\r",
"\t")
7259 @brief Describe why a directory value cannot be written to a PETSc options line.
7260 @param[in] value Configured directory value.
7261 @return Human-readable reason, or an empty string when the value is safe.
7263 if any(character.isspace()
for character
in value):
7264 return "contains whitespace"
7265 for character
in UNSAFE_DIRECTORY_CHARACTERS:
7266 if character
in value:
7267 label = {
'"':
"a double quote",
"'":
"a single quote",
"#":
"a comment marker"}.get(
7268 character,
"a control character"
7270 return f
"contains {label}"
7276 @brief Classify a configured run directory value.
7278 @details Containment is judged lexically against the run directory, because the run
7279 directory does not exist yet at validation time. Beyond escaping, a value
7280 is rejected when it resolves to the run root itself: the log directory is
7281 recursively deleted on a fresh solve, so `.` or `a/..` would delete the run.
7282 @param[in] value Configured directory value from monitor `io.directories`.
7283 @return One of "contained", "escaping", "tilde", "run_root", or "invalid".
7285 if not isinstance(value, str)
or not value.strip():
7287 candidate = value.strip()
7298 if candidate.startswith(
"~"):
7300 if os.path.isabs(candidate):
7302 normalized = os.path.normpath(candidate)
7303 if normalized == os.pardir
or normalized.startswith(os.pardir + os.sep):
7305 if normalized
in (
".",
""):
7312 @brief Normalized, comparable form of a contained run directory value.
7313 @param[in] value Configured directory value.
7314 @return Normalized relative path.
7316 return os.path.normpath(str(value).strip()).replace(os.sep,
"/")
7321 @brief Whether two run-relative directories are the same or nested in one another.
7322 @param[in] first Normalized directory.
7323 @param[in] second Normalized directory.
7324 @return True when one contains the other.
7328 return first.startswith(second +
"/")
or second.startswith(first +
"/")
7333 @brief Resolve the unsafe-paths override, requiring a real YAML boolean.
7335 @details A truthy string such as "false" must never enable an override that permits
7336 a destructive path. Only a genuine boolean `true` enables it; any other
7337 type is a configuration error rather than a silent interpretation.
7338 @param[in] dirs The `io.directories` mapping.
7339 @param[in] monitor_path Path to the monitor file, for error messages.
7340 @return Tuple of (enabled, errors).
7342 if UNSAFE_PATHS_OVERRIDE_KEY
not in dirs:
7344 raw = dirs[UNSAFE_PATHS_OVERRIDE_KEY]
7350 f
" {monitor_path}: 'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}' must be a YAML boolean "
7351 f
"(true or false), got {raw!r}. Quoted strings and numbers are rejected so a value like "
7352 f
"\"false\" cannot silently enable an unsafe path."
7358 @brief Apply every run-directory safety rule to a set of effective directory values.
7360 @details Single source of truth for the rules, shared by configuration validation and
7361 submission preflight so the two cannot drift apart. Callers must pass
7362 *effective* values with defaults filled in.
7364 Two classes of finding are distinguished. **Waivable** findings concern a
7365 deliberate external location; `allow_unsafe_paths` downgrades those to
7366 warnings. **Non-waivable** findings concern self-destruction or a value that
7367 cannot be written unambiguously - the run root itself, a reserved run
7368 directory, log/output overlap, and malformed characters. The override was
7369 granted for deliberate external storage, never for deleting the run's own
7370 config or emitting an ambiguous option line, so those stay errors.
7371 @param[in] values Effective mapping of directory key to configured value.
7372 @param[in] override Whether the unsafe-paths override is enabled.
7373 @param[in] explicit Keys the user actually configured; the rest are reported as defaults.
7374 @return Tuple of (errors, warnings) as bare messages without a file prefix.
7378 configured_keys = set(values)
if explicit
is None else set(explicit)
7380 def waivable(message: str) ->
None:
7382 @brief Record a finding the unsafe-paths override may downgrade.
7383 @param[in] message Finding text.
7388 f
"{message} Allowed only because '{UNSAFE_PATHS_OVERRIDE_KEY}: true' is set."
7392 f
"{message} Use a directory inside the run tree, or set "
7393 f
"'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}: true' to override deliberately."
7396 def fatal(message: str) ->
None:
7398 @brief Record a finding the override must never waive.
7399 @param[in] message Finding text.
7402 errors.append(f
"{message} This cannot be overridden.")
7404 destructive_note = (
7405 "On a fresh solve the runtime RECURSIVELY DELETES this directory before writing to it."
7408 for key
in RUN_OWNED_DIRECTORY_KEYS:
7409 if key
not in values:
7412 detail = destructive_note
if key ==
"log" else (
7413 "Run output must stay inside the run directory so it can be archived and restored."
7415 if not isinstance(raw, str)
or not raw.strip():
7416 fatal(f
"'io.directories.{key}' must be a non-empty relative path (got {raw!r}).")
7426 f
"'io.directories.{key}' = {raw!r} {charset_problem}. Run directory names must be "
7427 f
"writable to a PETSc options line without quoting; use a plain relative path such "
7428 f
"as 'logs' or 'diagnostics/run1'."
7430 if verdict ==
"tilde":
7432 f
"'io.directories.{key}' = {raw!r} starts with '~', which nothing expands. "
7433 f
"The control file is read by PETSc rather than by a shell, and the C "
7434 f
"runtime resolves a value not starting with '/' relative to the run - so "
7435 f
"this would be planned as one directory and deleted as another. Give a "
7436 f
"real absolute path if an external location is intended. This cannot be "
7440 if verdict ==
"escaping":
7441 if not (isinstance(raw, str)
and raw.strip().startswith(
"/")):
7443 f
"'io.directories.{key}' = {raw!r} escapes the run directory by relative "
7444 f
"traversal. {detail} A relative escape lands among sibling runs and study "
7445 f
"members, so it is never authorizable; give an absolute path if an external "
7446 f
"location is genuinely intended."
7452 waivable(f
"'io.directories.{key}' = {raw!r} escapes the run directory. {detail}")
7453 if verdict ==
"run_root":
7455 f
"'io.directories.{key}' = {raw!r} resolves to the run directory itself. "
7456 + (destructive_note +
" That would delete the entire run."
7457 if key ==
"log" else "Run output must live in its own subdirectory.")
7463 resolved[key] = normalized
7466 segments = [s
for s
in normalized.split(
"/")
if s
not in (
"",
".")]
7467 hit = next((s
for s
in segments
if s
in RESERVED_RUN_DIRECTORY_NAMES),
None)
7470 f
"'io.directories.{key}' = {raw!r} targets the reserved run directory "
7471 f
"'{hit}'. " + (destructive_note
if key ==
"log"
7472 else "That directory is owned by the run tree.")
7475 if "log" in resolved
and "output" in resolved
and paths_overlap(resolved[
"log"], resolved[
"output"]):
7476 log_source =
"" if "log" in configured_keys
else " (default)"
7477 out_source =
"" if "output" in configured_keys
else " (default)"
7479 f
"'io.directories.log' ({resolved['log']!r}{log_source}) and 'io.directories.output' "
7480 f
"({resolved['output']!r}{out_source}) overlap. {destructive_note} "
7481 f
"That would delete solver output."
7483 return errors, warnings
7490PHYSICAL_VERDICT_CONTAINED =
"contained"
7491PHYSICAL_VERDICT_RUN_ROOT =
"run_root"
7492PHYSICAL_VERDICT_ANCESTOR =
"ancestor"
7493PHYSICAL_VERDICT_RELATIVE_ESCAPE =
"relative_escape"
7494PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE =
"external_absolute"
7500WAIVABLE_PHYSICAL_VERDICTS = frozenset({PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE})
7505 @brief Classify where each run-owned directory physically lands.
7507 @details Lexical containment is not enough: a contained name can be a symlink to an
7508 external directory, and `PetscRMTree` follows symlinks. This resolves the
7509 real path - including any symlinked ancestor - and reports a typed verdict
7510 so the caller can apply the waiver rule structurally.
7511 @param[in] run_dir Run directory the values are relative to.
7512 @param[in] values Effective directory mapping.
7513 @return List of (key, verdict, message) for every value that is not contained.
7517 root = os.path.realpath(run_dir)
7520 for key
in RUN_OWNED_DIRECTORY_KEYS:
7521 raw = values.get(key)
7522 if not isinstance(raw, str)
or not raw.strip():
7532 absolute = text.startswith(
"/")
7533 candidate = os.path.join(run_dir, text)
7534 real = os.path.realpath(candidate)
7537 findings.append((key, PHYSICAL_VERDICT_RUN_ROOT,
7538 f
"'io.directories.{key}' = {raw!r} resolves to the run directory itself "
7539 f
"({real!r}); deleting it would destroy the run. This cannot be overridden."))
7540 elif real == os.sep
or root.startswith(real.rstrip(os.sep) + os.sep):
7541 findings.append((key, PHYSICAL_VERDICT_ANCESTOR,
7542 f
"'io.directories.{key}' = {raw!r} resolves to {real!r}, which CONTAINS the run "
7543 f
"directory {root!r}. The runtime deletes this path recursively, so it would "
7544 f
"destroy the run and everything beside it. This cannot be overridden."))
7545 elif real.startswith(root + os.sep):
7548 findings.append((key, PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE,
7549 f
"'io.directories.{key}' = {raw!r} resolves to {real!r}, which is outside the run "
7550 f
"directory {root!r}. The runtime deletes its log directory recursively."))
7552 findings.append((key, PHYSICAL_VERDICT_RELATIVE_ESCAPE,
7553 f
"'io.directories.{key}' = {raw!r} is a relative name that resolves to {real!r}, "
7554 f
"outside the run directory {root!r} - a symlink leads out of the tree. A "
7555 f
"relative escape is never authorizable; name an absolute path if an external "
7556 f
"location is genuinely intended. This cannot be overridden."))
7562 @brief Human-readable physical containment violations.
7563 @param[in] run_dir Run directory the values are relative to.
7564 @param[in] values Effective directory mapping.
7565 @return Violation lines.
7572 @brief Fill in defaults for run-owned directories that were not configured.
7574 @details An omitted key is not absent at runtime, it takes its default. Checking
7575 only explicit keys would miss `log: output`, which collides with the
7576 default output directory and would delete solver output.
7577 @param[in] configured Configured directory mapping, possibly partial.
7578 @return Effective mapping with defaults applied.
7580 effective = dict(RUN_DIRECTORY_DEFAULTS)
7581 for key
in RUN_OWNED_DIRECTORY_KEYS:
7582 if key
in configured:
7583 effective[key] = configured[key]
7589 @brief Classify legacy directory values as defense-in-depth during validation.
7590 @details The monitor schema rejects this removed surface. Keeping the stricter
7591 classifier here ensures malformed or manually constructed configurations
7592 still receive the safety findings that protect recursive log cleanup.
7593 @param[in] monitor_cfg Parsed monitor YAML dictionary.
7594 @param[in] monitor_path Path to the monitor file, for error messages.
7595 @return Tuple of (errors, warnings).
7597 io_cfg = (monitor_cfg
or {}).get(
"io")
or {}
7598 dirs = io_cfg.get(
"directories")
7599 if not isinstance(dirs, dict):
7606 override_errors + [f
" {monitor_path}: {message}" for message
in errors],
7607 [f
" {monitor_path}: {message}" for message
in warnings],
7613 @brief Reject raw PETSc passthrough options that set run-owned directories.
7615 @details Passthrough surfaces emit `{flag: value}` verbatim into the generated
7616 control file. Run-owned path flags are reserved for the fixed workspace
7617 topology and may only be emitted by the generator.
7618 @param[in] config Parsed configuration mapping to scan.
7619 @param[in] config_path Path to the file, for error messages.
7620 @param[in] label Human-readable description of the surface being scanned.
7621 @return Violation lines.
7623 violations: list = []
7625 def scan(node, trail: str) ->
None:
7627 @brief Walk the mapping looking for reserved flags used as keys.
7628 @param[in] node Current mapping, list, or scalar node.
7629 @param[in] trail Dotted path to the current node, for error messages.
7632 if isinstance(node, dict):
7633 for key, value
in node.items():
7634 token = key.strip()
if isinstance(key, str)
else key
7635 if token
in RESERVED_DIRECTORY_FLAGS:
7637 f
" {config_path}: {label} sets the reserved flag '{token}' at "
7638 f
"{trail or '<root>'}. Run directories are fixed by the workspace "
7639 "contract; raw passthrough cannot override them."
7641 elif token
in RESERVED_INDIRECTION_FLAGS:
7643 f
" {config_path}: {label} sets '{token}' at {trail or '<root>'}. PETSc "
7644 f
"evaluates that indirection itself, so its contents cannot be checked "
7645 "here and could reintroduce a run-directory flag. Remove the indirection."
7647 scan(value, f
"{trail}.{key}" if trail
else str(key))
7648 elif isinstance(node, list):
7649 for index, item
in enumerate(node):
7650 scan(item, f
"{trail}[{index}]")
7657 case_path: str, solver_path: str, monitor_path: str):
7659 @brief Validates every configuration a simulation run consumes, before any work is done.
7660 @details Covers the three roles the solver is launched with: the case, the solver,
7661 and the monitor. Only one of those is a solver configuration, which is
7662 why this is not named for the solver; the monitor in particular carries
7663 observation and field-statistics contracts that have nothing to do with
7664 the numerical scheme.
7666 Checks required sections, required keys, physical sanity, and the
7667 cross-file combinations no single file can rule out. Post-processing
7668 configuration is validated separately by `validate_post_config()`.
7669 @param[in] case_cfg Parsed case YAML dictionary.
7670 @param[in] solver_cfg Parsed solver YAML dictionary.
7671 @param[in] monitor_cfg Parsed monitor YAML dictionary.
7672 @param[in] case_path Path to case file (for error messages).
7673 @param[in] solver_path Path to solver file (for error messages).
7674 @param[in] monitor_path Path to monitor file (for error messages).
7675 @throws SystemExit on validation failure.
7680 case_cfg, case_path,
"case solver_parameters / passthrough"))
7682 solver_cfg, solver_path,
"solver petsc_passthrough_options"))
7683 eulerian_source_mode =
"solve"
7685 legacy_statistics = (case_cfg.get(
"models", {})
or {}).get(
"statistics")
if isinstance(case_cfg, dict)
else None
7686 if legacy_statistics
is not None:
7688 f
" {case_path}: 'models.statistics' was removed with the legacy averaging system. "
7689 "Use instantaneous output and offline postprocessing until the replacement "
7690 "field-statistics pipeline is available."
7692 if isinstance(solver_cfg, dict)
and "solution_convergence" in solver_cfg:
7694 f
" {solver_path}: 'solution_convergence' moved to "
7695 "monitor.yml -> solution_monitoring.convergence."
7705 required_case_sections = [
'properties',
'run_control',
'grid',
'models',
'boundary_conditions']
7706 for section
in required_case_sections:
7707 if section
not in case_cfg:
7708 errors.append(f
" {case_path}: missing required section '{section}'.")
7714 props = case_cfg.get(
'properties', {})
7715 for group, keys
in [(
'scaling', [
'length_ref',
'velocity_ref']),
7716 (
'fluid', [
'density',
'viscosity'])]:
7717 sub = props.get(group, {})
7719 errors.append(f
" {case_path}: missing 'properties.{group}' section.")
7723 errors.append(f
" {case_path}: missing key 'properties.{group}.{k}'.")
7726 rc = case_cfg.get(
'run_control', {})
7727 for k
in [
'start_step',
'total_steps',
'dt_physical']:
7729 errors.append(f
" {case_path}: missing key 'run_control.{k}'.")
7733 density = float(props.get(
'fluid', {}).get(
'density', 0))
7734 viscosity = float(props.get(
'fluid', {}).get(
'viscosity', 0))
7735 dt = float(rc.get(
'dt_physical', 0))
7737 errors.append(f
" {case_path}: 'properties.fluid.density' must be positive (got {density}).")
7739 errors.append(f
" {case_path}: 'properties.fluid.viscosity' must be non-negative (got {viscosity}).")
7741 errors.append(f
" {case_path}: 'run_control.dt_physical' must be positive (got {dt}).")
7742 except (TypeError, ValueError):
7746 grid_cfg = case_cfg.get(
'grid', {})
7747 grid_mode = grid_cfg.get(
'mode')
7748 valid_grid_modes =
list(GRID_MODES)
7749 if grid_mode
not in valid_grid_modes:
7750 errors.append(f
" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
7751 elif grid_mode ==
'file':
7752 source_file = grid_cfg.get(
'source_file')
7754 errors.append(f
" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
7758 except ValueError
as exc:
7759 errors.append(f
" {case_path}: {exc}")
7761 if not os.path.isfile(source_abs):
7762 errors.append(f
" {case_path}: grid.source_file does not exist: {source_abs}")
7763 elif grid_mode ==
'programmatic_c':
7764 grid_settings = grid_cfg.get(
'programmatic_settings')
7765 if not grid_settings:
7766 errors.append(f
" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
7767 elif not isinstance(grid_settings, dict):
7768 errors.append(f
" {case_path}: 'grid.programmatic_settings' must be a mapping.")
7769 elif grid_mode ==
'grid_gen':
7770 gen_cfg = grid_cfg.get(
'generator')
7771 if not isinstance(gen_cfg, dict):
7772 errors.append(f
" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
7779 config_file = gen_cfg.get(
'config_file')
7781 errors.append(f
" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
7785 except ValueError
as exc:
7786 errors.append(f
" {case_path}: {exc}")
7788 if not os.path.isfile(config_abs):
7789 errors.append(f
" {case_path}: grid.generator.config_file does not exist: {config_abs}")
7791 grid_type = gen_cfg.get(
'grid_type')
7792 if grid_type
is not None and str(grid_type)
not in GRID_GENERATOR_TYPES:
7793 errors.append(f
" {case_path}: grid.generator.grid_type must be one of "
7794 f
"{list(GRID_GENERATOR_TYPES)} (got '{grid_type}').")
7801 cli_args = gen_cfg.get(
'cli_args', [])
7802 if cli_args
is not None and not isinstance(cli_args, list):
7803 errors.append(f
" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
7806 except ValueError
as e:
7807 errors.append(f
" {case_path}: {e}")
7810 prepared_blocks =
None
7813 except ValueError
as e:
7814 errors.append(f
" {case_path}: {e}")
7817 ic = props.get(
'initial_conditions', {})
7820 ic_start_step = int((case_cfg.get(
"run_control", {})
or {}).get(
"start_step", 0)
or 0)
7821 except (TypeError, ValueError):
7825 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
7830 ic_eulerian_source =
"solve"
7831 ic_is_authoritative = ic_eulerian_source ==
"solve" and ic_start_step == 0
7833 errors.append(f
" {case_path}: missing 'properties.initial_conditions' section.")
7834 elif not isinstance(ic, dict):
7835 errors.append(f
" {case_path}: 'properties.initial_conditions' must be a mapping.")
7836 elif 'mode' not in ic:
7838 f
" {case_path}: missing key 'properties.initial_conditions.mode'. "
7839 "Specify 'generated' or 'file' explicitly."
7841 elif ic_is_authoritative:
7845 ic, prepared_blocks, U_ref=scaling_contract[
"velocity_ref"],
7846 provider_context={
"kinematic_viscosity": scaling_contract[
"nondimensional_kinematic_viscosity"]},
7848 except KeyError
as e:
7849 errors.append(f
" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
7850 except ValueError
as e:
7851 errors.append(f
" {case_path}: {e}")
7852 if (ic_is_authoritative
and resolved_ic
and
7853 GENERATED_IC_PROVIDERS.get(resolved_ic.get(
"kind"), {}).get(
"requires_fresh_3d")):
7854 dimensionality = str((((case_cfg.get(
"models", {})
or {}).get(
"physics", {})
or {})
7855 .get(
"dimensionality",
"3D"))).strip().upper()
7856 if dimensionality !=
"3D":
7857 errors.append(f
" {case_path}: {resolved_ic['label']} requires models.physics.dimensionality: 3D.")
7858 if grid_mode ==
"programmatic_c":
7859 settings = grid_cfg.get(
"programmatic_settings", {})
or {}
7860 ratios = [settings.get(key, 1.0)
for key
in (
"rxs",
"rys",
"rzs")]
7862 if any(abs(float(value) - 1.0) > 1.0e-12
for value
in ratios):
7863 errors.append(f
" {case_path}: {resolved_ic['label']} requires uniform programmatic spacing (rxs/rys/rzs: 1.0).")
7864 except (TypeError, ValueError):
7869 except ValueError
as e:
7870 errors.append(f
" {case_path}: {e}")
7873 particles_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'particles', {})
7874 if particles_cfg
and not isinstance(particles_cfg, dict):
7875 errors.append(f
" {case_path}: 'models.physics.particles' must be a mapping.")
7876 elif isinstance(particles_cfg, dict):
7877 init_mode_raw = particles_cfg.get(
'init_mode',
'Surface')
7880 except ValueError
as e:
7881 errors.append(f
" {case_path}: {e}")
7884 restart_mode = particles_cfg.get(
'restart_mode')
7885 if restart_mode
is not None and str(restart_mode).lower()
not in PARTICLE_RESTART_MODES:
7887 f
" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
7889 elif 'restart_mode' not in particles_cfg:
7891 start_step = int(rc.get(
'start_step', 0))
7892 particle_count = int(particles_cfg.get(
'count', 0)
or 0)
7893 except (TypeError, ValueError):
7896 if start_step > 0
and particle_count > 0:
7898 f
"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
7899 "(run_control.start_step > 0, count > 0). C will default to 'load'."
7903 point_cfg = particles_cfg.get(
'point_source', {})
7904 if not isinstance(point_cfg, dict):
7905 errors.append(f
" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
7907 for coord
in (
'x',
'y',
'z'):
7908 if coord
not in point_cfg:
7910 f
" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
7914 turbulence_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'turbulence', {})
7915 if turbulence_cfg
is not None and not isinstance(turbulence_cfg, dict):
7916 errors.append(f
" {case_path}: 'models.physics.turbulence' must be a mapping.")
7917 elif isinstance(turbulence_cfg, dict)
and turbulence_cfg:
7920 except ValueError
as e:
7921 errors.append(f
" {case_path}: {e}")
7923 les_cfg = turbulence_cfg.get(
'les')
7924 rans_cfg = turbulence_cfg.get(
'rans')
7925 wall_cfg = turbulence_cfg.get(
'wall_function')
7927 if isinstance(les_cfg, dict):
7933 if isinstance(rans_cfg, dict):
7934 if 'enabled' in rans_cfg
and not isinstance(rans_cfg[
'enabled'], bool):
7935 errors.append(f
" {case_path}: models.physics.turbulence.rans.enabled must be true or false.")
7937 rans_enabled = bool(rans_cfg.get(
'enabled',
True))
and normalize_rans_model(rans_cfg.get(
'model',
'k_omega')) != 0
7939 rans_enabled =
False
7942 f
"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
7946 f
"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
7949 if isinstance(wall_cfg, dict):
7950 if 'enabled' in wall_cfg
and not isinstance(wall_cfg[
'enabled'], bool):
7951 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
7952 if 'roughness_height' in wall_cfg:
7954 value = float(wall_cfg[
'roughness_height'])
7956 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
7957 except (TypeError, ValueError):
7958 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
7966 if wall_model
in (2, 3):
7968 f
" {case_path}: models.physics.turbulence.wall_function.roughness_height "
7969 "applies only to model 'log_law'; 'werner' has no roughness formulation and "
7970 "'cabot' ignores it. Remove the key or select 'log_law'.")
7973 if not isinstance(solver_cfg, dict)
or not solver_cfg:
7974 errors.append(f
" {solver_path}: solver config is empty or not a valid YAML mapping.")
7976 strategy_cfg = solver_cfg.get(
'strategy', {})
7977 if not isinstance(strategy_cfg, dict):
7978 errors.append(f
" {solver_path}: 'strategy' must be a mapping.")
7979 elif 'implicit' in strategy_cfg:
7981 f
" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
7982 "Use 'strategy.momentum_solver' with named solver values."
7984 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
7987 except ValueError
as e:
7988 errors.append(f
" {solver_path}: {e}")
7990 op_mode_cfg = solver_cfg.get(
'operation_mode', {})
7991 if op_mode_cfg
is not None and not isinstance(op_mode_cfg, dict):
7992 errors.append(f
" {solver_path}: 'operation_mode' must be a mapping when provided.")
7993 elif isinstance(op_mode_cfg, dict):
7994 eulerian_source_mode =
None
7995 normalized_analytical_type =
None
7996 if 'eulerian_field_source' in op_mode_cfg:
7999 except ValueError
as e:
8000 errors.append(f
" {solver_path}: {e}")
8002 analytical_type = op_mode_cfg.get(
'analytical_type')
8003 if analytical_type
is not None:
8006 except ValueError
as e:
8007 errors.append(f
" {solver_path}: {e}")
8009 uniform_flow_cfg = op_mode_cfg.get(
'uniform_flow')
8010 if uniform_flow_cfg
is not None and not isinstance(uniform_flow_cfg, dict):
8011 errors.append(f
" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
8012 elif normalized_analytical_type ==
"UNIFORM_FLOW":
8013 if not isinstance(uniform_flow_cfg, dict):
8015 f
" {solver_path}: operation_mode.uniform_flow is required when "
8016 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
8019 for coord
in (
"u",
"v",
"w"):
8020 if coord
not in uniform_flow_cfg:
8022 f
" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
8026 float(uniform_flow_cfg[coord])
8027 except (TypeError, ValueError):
8029 f
" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
8031 elif uniform_flow_cfg
is not None:
8033 f
" {solver_path}: operation_mode.uniform_flow is only valid when "
8034 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
8037 if eulerian_source_mode ==
"analytical":
8038 effective_analytical_type = normalized_analytical_type
or "TGV3D"
8039 if effective_analytical_type ==
"TGV3D":
8040 if grid_mode !=
'programmatic_c':
8042 f
" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
8043 "'programmatic_c'. File-backed analytical ingestion is only supported for "
8044 "ZERO_FLOW and UNIFORM_FLOW."
8046 elif isinstance(grid_cfg.get(
'programmatic_settings'), dict):
8047 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
8050 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
8051 f
"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
8054 if grid_mode
not in (GRID_MODES[1], GRID_MODES[0]):
8056 f
" {case_path}: grid.mode '{grid_mode}' is not supported when "
8057 f
"operation_mode.analytical_type is '{effective_analytical_type}'. "
8058 "Use 'programmatic_c' or 'file'."
8060 elif grid_mode ==
'programmatic_c' and isinstance(grid_cfg.get(
'programmatic_settings'), dict):
8061 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
8064 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
8065 f
"operation_mode.analytical_type is '{effective_analytical_type}' and "
8066 "grid.mode is 'programmatic_c'."
8069 verification_cfg = solver_cfg.get(
'verification', {})
8070 if verification_cfg
is not None and not isinstance(verification_cfg, dict):
8071 errors.append(f
" {solver_path}: 'verification' must be a mapping when provided.")
8072 elif isinstance(verification_cfg, dict)
and verification_cfg:
8073 sources_cfg = verification_cfg.get(
'sources', {})
8074 if sources_cfg
is not None and not isinstance(sources_cfg, dict):
8075 errors.append(f
" {solver_path}: 'verification.sources' must be a mapping when provided.")
8076 elif isinstance(sources_cfg, dict)
and sources_cfg:
8077 diff_cfg = sources_cfg.get(
'diffusivity')
8078 scalar_cfg = sources_cfg.get(
'scalar')
8080 if diff_cfg
is not None:
8081 if not isinstance(diff_cfg, dict):
8082 errors.append(f
" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
8084 if eulerian_source_mode !=
"analytical":
8086 f
" {solver_path}: verification.sources.diffusivity is only valid when "
8087 "operation_mode.eulerian_field_source is 'analytical'."
8089 mode = diff_cfg.get(
'mode')
8090 profile = diff_cfg.get(
'profile')
8091 if str(mode).strip().lower() !=
"analytical":
8093 f
" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
8095 if str(profile).strip().upper() !=
"LINEAR_X":
8097 f
" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
8099 for key
in (
"gamma0",
"slope_x"):
8100 if key
not in diff_cfg:
8102 f
" {solver_path}: verification.sources.diffusivity.{key} is required."
8106 float(diff_cfg[key])
8107 except (TypeError, ValueError):
8109 f
" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
8112 if scalar_cfg
is not None:
8113 if not isinstance(scalar_cfg, dict):
8114 errors.append(f
" {solver_path}: 'verification.sources.scalar' must be a mapping.")
8116 if eulerian_source_mode !=
"analytical":
8118 f
" {solver_path}: verification.sources.scalar is only valid when "
8119 "operation_mode.eulerian_field_source is 'analytical'."
8121 mode = scalar_cfg.get(
'mode')
8122 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
8123 if str(mode).strip().lower() !=
"analytical":
8125 f
" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
8127 if profile
not in VERIFICATION_SCALAR_PROFILES:
8129 f
" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
8131 required_scalar_keys = {
8132 "CONSTANT": (
"value",),
8133 "LINEAR_X": (
"phi0",
"slope_x"),
8134 "SIN_PRODUCT": (
"amplitude",
"kx",
"ky",
"kz"),
8136 for key
in required_scalar_keys:
8137 if key
not in scalar_cfg:
8139 f
" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
8143 float(scalar_cfg[key])
8144 except (TypeError, ValueError):
8146 f
" {solver_path}: verification.sources.scalar.{key} must be numeric."
8149 unknown_source_keys = sorted(set(sources_cfg.keys()) - {
"diffusivity",
"scalar"})
8150 if unknown_source_keys:
8152 f
" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
8153 "Currently supported: 'diffusivity', 'scalar'."
8155 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {
"sources"})
8156 if unknown_verification_keys:
8158 f
" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
8159 "Currently supported: 'sources'."
8162 transport_cfg = solver_cfg.get(
'scalar_transport', {})
8163 if transport_cfg
is not None and not isinstance(transport_cfg, dict):
8164 errors.append(f
" {solver_path}: 'scalar_transport' must be a mapping when provided.")
8165 elif isinstance(transport_cfg, dict):
8166 unknown_transport_keys = sorted(set(transport_cfg.keys()) - {
"schmidt_number",
"turbulent_schmidt_number"})
8167 if unknown_transport_keys:
8169 f
" {solver_path}: unsupported scalar_transport entries: {unknown_transport_keys}. "
8170 "Currently supported: 'schmidt_number', 'turbulent_schmidt_number'."
8172 for key
in (
"schmidt_number",
"turbulent_schmidt_number"):
8173 if key
in transport_cfg:
8175 value = float(transport_cfg[key])
8177 errors.append(f
" {solver_path}: scalar_transport.{key} must be positive.")
8178 except (TypeError, ValueError):
8179 errors.append(f
" {solver_path}: scalar_transport.{key} must be numeric.")
8181 tolerances_cfg = solver_cfg.get(
'tolerances', {})
8182 if tolerances_cfg
is not None and not isinstance(tolerances_cfg, dict):
8183 errors.append(f
" {solver_path}: 'tolerances' must be a mapping when provided.")
8184 elif isinstance(tolerances_cfg, dict):
8185 for key
in (
"absolute_tol",
"relative_tol",
"residual_absolute_tol",
"residual_relative_tol"):
8186 if key
in tolerances_cfg:
8188 float(tolerances_cfg[key])
8189 except (TypeError, ValueError):
8190 errors.append(f
" {solver_path}: tolerances.{key} must be numeric.")
8192 ms_cfg = solver_cfg.get(
'momentum_solver', {})
8193 if ms_cfg
is not None and not isinstance(ms_cfg, dict):
8194 errors.append(f
" {solver_path}: 'momentum_solver' must be a mapping when provided.")
8195 elif isinstance(ms_cfg, dict):
8196 unsupported_flat_keys = {
8197 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
8198 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
8199 'rk4_residual_noise_allowance_factor'
8201 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
8202 if present_unsupported:
8204 f
" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
8205 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
8208 allowed_ms_keys = {
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4',
'newton_krylov'}
8209 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
8212 f
" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
8213 "Currently supported: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
8215 if 'dual_time_picard_jameson_rk' in ms_cfg
and 'dual_time_picard_rk4' in ms_cfg:
8217 f
" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
8218 "do not also set its deprecated dual_time_picard_rk4 alias."
8221 selected_solver =
None
8222 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
8227 if selected_solver
is None:
8228 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
8230 has_dualtime_block = (
8231 'dual_time_picard_jameson_rk' in ms_cfg
or 'dual_time_picard_rk4' in ms_cfg
8233 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
8235 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
8236 f
"{selected_solver}. Solver-specific blocks must match the selected solver."
8239 newton_cfg = ms_cfg.get(
'newton_krylov')
8240 if newton_cfg
is not None:
8241 if selected_solver !=
"newton_krylov":
8243 f
" {solver_path}: momentum_solver.newton_krylov is set but selected solver is "
8244 f
"{selected_solver}. Solver-specific blocks must match the selected solver."
8248 except ValueError
as exc:
8249 errors.append(f
" {solver_path}: {exc}")
8251 dt_picard_cfg = ms_cfg.get(
'dual_time_picard_jameson_rk', ms_cfg.get(
'dual_time_picard_rk4'))
8252 if dt_picard_cfg
is not None:
8253 if not isinstance(dt_picard_cfg, dict):
8254 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
8257 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
8258 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
8259 'rk4_residual_noise_allowance_factor',
'ratio_ema_alpha'
8261 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
8264 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
8266 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
8267 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
8269 f
" {solver_path}: use only jameson_residual_noise_allowance_factor; "
8270 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
8272 if 'pseudo_cfl' in dt_picard_cfg:
8273 pcfl_cfg = dt_picard_cfg[
'pseudo_cfl']
8274 if not isinstance(pcfl_cfg, dict):
8275 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
8277 allowed_pcfl_keys = {
'initial',
'minimum',
'maximum',
'growth_factor',
'reduction_factor'}
8278 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
8279 if unknown_pcfl_keys:
8281 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
8284 for key
in allowed_pcfl_keys:
8287 numeric_pcfl[key] = float(pcfl_cfg[key])
8288 except (TypeError, ValueError):
8290 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
8292 if numeric_pcfl.get(
'minimum', 1.0) <= 0.0:
8293 errors.append(f
" {solver_path}: pseudo_cfl.minimum must be positive.")
8294 if numeric_pcfl.get(
'growth_factor', 1.0) < 1.0:
8295 errors.append(f
" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
8296 reduction = numeric_pcfl.get(
'reduction_factor', 1.0)
8297 if reduction <= 0.0
or reduction >= 1.0:
8298 errors.append(f
" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
8299 if all(key
in numeric_pcfl
for key
in (
'minimum',
'initial',
'maximum')):
8300 if not numeric_pcfl[
'minimum'] <= numeric_pcfl[
'initial'] <= numeric_pcfl[
'maximum']:
8301 errors.append(f
" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
8303 'jameson_residual_noise_allowance_factor'
8304 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
8305 else 'rk4_residual_noise_allowance_factor'
8307 if noise_key
in dt_picard_cfg:
8309 if float(dt_picard_cfg[noise_key]) < 1.0:
8310 errors.append(f
" {solver_path}: {noise_key} must be at least 1.")
8311 except (TypeError, ValueError):
8312 errors.append(f
" {solver_path}: {noise_key} must be numeric.")
8313 if 'ratio_ema_alpha' in dt_picard_cfg:
8315 alpha_val = float(dt_picard_cfg[
'ratio_ema_alpha'])
8316 if not 0.0 <= alpha_val <= 1.0:
8317 errors.append(f
" {solver_path}: ratio_ema_alpha must be in [0, 1].")
8318 except (TypeError, ValueError):
8319 errors.append(f
" {solver_path}: ratio_ema_alpha must be numeric.")
8322 interp_cfg = solver_cfg.get(
'interpolation', {})
if isinstance(solver_cfg, dict)
else {}
8323 if interp_cfg
is not None and not isinstance(interp_cfg, dict):
8324 errors.append(f
" {solver_path}: 'interpolation' must be a mapping when provided.")
8325 elif isinstance(interp_cfg, dict)
and 'method' in interp_cfg:
8328 except ValueError
as e:
8329 errors.append(f
" {solver_path}: {e}")
8332 if not isinstance(monitor_cfg, dict)
or not monitor_cfg:
8333 errors.append(f
" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
8335 io_cfg = monitor_cfg.get(
'io', {})
8336 freq = io_cfg.get(
'data_output_frequency')
8337 if freq
is not None and (
not isinstance(freq, int)
or freq <= 0):
8338 errors.append(f
" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
8339 particle_console_freq = io_cfg.get(
'particle_console_output_frequency')
8340 if particle_console_freq
is not None and (
not isinstance(particle_console_freq, int)
or particle_console_freq < 0):
8342 f
" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
8343 f
"(got {particle_console_freq})."
8346 monitor_cfg, monitor_path
8348 errors.extend(containment_errors)
8349 warnings.extend(containment_warnings)
8351 monitor_cfg, monitor_path,
"monitor passthrough"))
8354 except ValueError
as e:
8355 errors.append(f
" {monitor_path}: {e}")
8358 except ValueError
as e:
8359 errors.append(f
" {monitor_path}: {e}")
8362 except ValueError
as e:
8363 errors.append(f
" {monitor_path}: {e}")
8366 except ValueError
as e:
8367 errors.append(f
" {monitor_path}: {e}")
8368 statistics_console_freq = io_cfg.get(
'statistics_console_output_frequency')
8369 if statistics_console_freq
is not None and (
8370 not isinstance(statistics_console_freq, int)
8371 or isinstance(statistics_console_freq, bool)
8372 or statistics_console_freq < 0):
8374 f
" {monitor_path}: 'io.statistics_console_output_frequency' must be a non-negative "
8375 f
"integer (got {statistics_console_freq})."
8379 except ValueError
as e:
8380 errors.append(f
" {monitor_path}: {e}")
8385 f
"{case_path}: This configuration requires restart data (start_step > 0, "
8386 "eulerian_field_source='load', or particle restart_mode='load'). "
8387 "Use --restart-from or --continue when running."
8392 for warning
in warnings:
8393 print(f
"[WARN] {warning}", file=sys.stderr)
8397 monitor_path: str =
"monitor.yml") ->
"tuple[list, list]":
8399 @brief Report post step selections that cannot land on a committed checkpoint.
8401 @details The post-processor reads committed bundles, and the solver commits one
8402 every `io.data_output_frequency` completed steps. A `step_interval` that
8403 is not a multiple of that cadence therefore asks for steps that were
8404 never written: the source-frontier scan stops at the first missing one
8405 and processes far less than the recipe requested. That is only
8406 discovered after a solve has already run, so it is caught here instead.
8408 The solver also commits the initial and final states off cadence, which
8409 is why a misaligned `start_step` is a warning rather than an error: it
8410 may legitimately be the run's own starting step. `step_interval` has no
8411 such exemption, because a stride off the cadence cannot land on two
8412 consecutive checkpoints whatever the run's bounds are.
8414 @param[in] post_cfg Parsed post-processing configuration.
8415 @param[in] monitor_cfg Parsed monitor configuration governing the source run.
8416 @param[in] post_path Path to the post file, for error messages.
8417 @param[in] monitor_path Path to the monitor file, for error messages.
8418 @return `(errors, warnings)`, each a list of message strings. Both are empty when
8419 the comparison cannot be made, because the inputs that would make it
8420 meaningful are validated and reported elsewhere.
8424 if not isinstance(post_cfg, dict)
or not isinstance(monitor_cfg, dict):
8425 return errors, warnings
8427 io_cfg = monitor_cfg.get(
"io")
or {}
8428 if not isinstance(io_cfg, dict):
8429 return errors, warnings
8431 cadence = int(io_cfg[
"data_output_frequency"])
8432 except (KeyError, TypeError, ValueError):
8434 return errors, warnings
8438 return errors, warnings
8443 except (TypeError, ValueError):
8445 return errors, warnings
8447 if step_interval > 0
and step_interval % cadence != 0:
8448 suggestion = max(cadence, (step_interval // cadence) * cadence)
8450 f
" {post_path}: 'run_control.step_interval' is {step_interval}, which is not a "
8451 f
"multiple of 'io.data_output_frequency' ({cadence}) in {monitor_path}. The solver "
8452 f
"only commits a checkpoint every {cadence} steps, so most requested steps were "
8453 f
"never written and post-processing would stop at the first missing one. Use "
8454 f
"{suggestion}, or another multiple of {cadence}, or lower the monitor cadence."
8457 if start_step > 0
and start_step % cadence != 0:
8459 f
"{post_path}: 'run_control.start_step' is {start_step}, which is not a multiple of "
8460 f
"'io.data_output_frequency' ({cadence}) in {monitor_path}. That step only exists if "
8461 f
"it is the run's own starting step, which is committed off cadence."
8464 return errors, warnings
8469 @brief Validates the post-processing config before running the post-processor.
8470 @param[in] post_cfg Parsed post-processing YAML dictionary.
8471 @param[in] post_path Path to post file (for error messages).
8472 @param[in] monitor_cfg Parsed monitor configuration, when available. Two checks
8473 span both files: field statistics, where post.yml names
8474 the windows and monitor.yml decides what each accumulates,
8475 and step cadence, where monitor.yml decides which steps
8476 exist to be read. Both run only when the monitor is known.
8477 @param[in] case_cfg Parsed case configuration, when available. Spectra
8478 preconditions need the boundary conditions and block count, so
8479 they are checked only when the case is known; validating a
8480 recipe on its own still checks it on its own terms.
8481 @throws SystemExit on validation failure.
8490 except ValueError
as e:
8492 errors.append(f
" {post_path}: {e}")
8496 except ValueError
as e:
8497 spectra_recipe =
None
8498 errors.append(f
" {post_path}: {e}")
8499 if spectra_recipe
and spectra_recipe[
"tasks"]
and case_cfg
is not None:
8501 if recipe
and recipe[
"windows"]
and monitor_cfg
is not None:
8507 if configured
is not None:
8508 if not configured[
"enabled"]:
8510 f
" {post_path}: field statistics are requested, but "
8511 "'field_statistics.enabled' is not set in the monitor configuration, so no "
8512 "window is accumulated."
8515 by_name = {window[
"name"]: window
for window
in configured[
"windows"]}
8516 for name
in recipe[
"windows"]:
8517 if name
not in by_name:
8519 f
" {post_path}: field-statistics window '{name}' is not defined in "
8520 f
"the monitor configuration. Defined windows: {sorted(by_name)}."
8524 f
" {post_path}: outputs {recipe['outputs']} produce no field for "
8525 f
"window '{name}'; it accumulates none of the state they need. Add "
8526 "'second' to a field's moments for stresses, RMS, or turbulent "
8527 "kinetic energy, or a covariance for a flux."
8530 if not isinstance(post_cfg, dict)
or not post_cfg:
8531 errors.append(f
" {post_path}: post-processing config is empty or not a valid YAML mapping.")
8535 if 'run_control' not in post_cfg:
8536 errors.append(f
" {post_path}: missing required section 'run_control'.")
8538 rc = post_cfg.get(
'run_control', {})
8539 if not isinstance(rc, dict):
8540 errors.append(f
" {post_path}: 'run_control' must be a mapping.")
8542 for canonical_key, aliases
in POST_RUN_CONTROL_ALIASES.items():
8543 if not any(alias
in rc
for alias
in aliases):
8544 alias_list =
"', '".join(aliases)
8546 f
" {post_path}: missing required key 'run_control.{canonical_key}' "
8547 f
"(accepted aliases: '{alias_list}')."
8553 except (TypeError, ValueError):
8554 alias_name = next((alias
for alias
in aliases
if alias
in rc), canonical_key)
8556 f
" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
8560 io_cfg = post_cfg.get(
'io', {})
8561 source_cfg = post_cfg.get(
'source_data')
8562 if source_cfg
is not None and not isinstance(source_cfg, dict):
8563 errors.append(f
" {post_path}: 'source_data' must be a mapping when provided.")
8564 global_ops = post_cfg.get(
'global_operations')
8565 if global_ops
is not None:
8566 if not isinstance(global_ops, dict):
8567 errors.append(f
" {post_path}: 'global_operations' must be a mapping when provided.")
8568 elif 'dimensionalize' in global_ops
and not isinstance(global_ops.get(
'dimensionalize'), bool):
8569 errors.append(f
" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
8571 errors.append(f
" {post_path}: missing required section 'io'.")
8572 elif not isinstance(io_cfg, dict):
8573 errors.append(f
" {post_path}: 'io' must be a mapping.")
8575 for k
in [
'output_filename_prefix']:
8577 errors.append(f
" {post_path}: missing required key 'io.{k}'.")
8578 for key_name
in (
'output_directory',
'output_filename_prefix',
'particle_filename_prefix'):
8579 if key_name
in io_cfg
and not isinstance(io_cfg.get(key_name), str):
8580 errors.append(f
" {post_path}: 'io.{key_name}' must be a string when provided.")
8581 if 'output_particles' in io_cfg
and not isinstance(io_cfg.get(
'output_particles'), bool):
8582 errors.append(f
" {post_path}: 'io.output_particles' must be a boolean when provided.")
8583 particle_subsampling_frequency = io_cfg.get(
'particle_subsampling_frequency')
8584 if particle_subsampling_frequency
is not None:
8585 if not isinstance(particle_subsampling_frequency, int)
or particle_subsampling_frequency <= 0:
8587 f
" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
8589 input_extensions = io_cfg.get(
'input_extensions')
8591 if input_extensions
is not None:
8592 if not isinstance(input_extensions, dict):
8593 errors.append(f
" {post_path}: 'io.input_extensions' must be a mapping when provided.")
8595 for ext_key
in (
'eulerian',
'particle'):
8596 ext_val = input_extensions.get(ext_key)
8597 if ext_val
is not None and not isinstance(ext_val, str):
8598 errors.append(f
" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
8599 elif ext_val
is not None and str(ext_val).strip().lstrip(
'.').lower() !=
'dat':
8601 f
" {post_path}: 'io.input_extensions.{ext_key}' must be 'dat'; "
8602 "committed checkpoint payload names are fixed."
8604 if source_input_extensions
is not None:
8605 if not isinstance(source_input_extensions, dict):
8606 errors.append(f
" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
8608 for ext_key
in (
'eulerian',
'particle'):
8609 ext_val = source_input_extensions.get(ext_key)
8610 if ext_val
is not None and not isinstance(ext_val, str):
8612 f
" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
8614 elif ext_val
is not None and str(ext_val).strip().lstrip(
'.').lower() !=
'dat':
8616 f
" {post_path}: 'source_data.input_extensions.{ext_key}' must be 'dat'; "
8617 "committed checkpoint payload names are fixed."
8620 for list_key
in (
'eulerian_fields',
'particle_fields'):
8621 list_val = io_cfg.get(list_key)
8622 if list_val
is not None and not isinstance(list_val, list):
8623 errors.append(f
" {post_path}: 'io.{list_key}' must be a list when provided.")
8626 eulerian_pipeline = post_cfg.get(
'eulerian_pipeline', [])
8627 if eulerian_pipeline
is not None and not isinstance(eulerian_pipeline, list):
8628 errors.append(f
" {post_path}: 'eulerian_pipeline' must be a list when provided.")
8629 eulerian_pipeline = []
8630 for i, entry
in enumerate(eulerian_pipeline):
8631 if not isinstance(entry, dict)
or 'task' not in entry:
8632 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
8633 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
8635 task_name = entry.get(
'task')
8636 if task_name ==
'q_criterion':
8638 if task_name ==
'nodal_average':
8639 in_field = entry.get(
'input_field')
8640 out_field = entry.get(
'output_field')
8641 if not isinstance(in_field, str)
or not in_field.strip():
8642 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
8643 if not isinstance(out_field, str)
or not out_field.strip():
8644 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
8645 if isinstance(in_field, str)
and isinstance(out_field, str)
and in_field == out_field:
8647 f
" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
8650 if task_name ==
'normalize_field':
8651 field = entry.get(
'field',
'P')
8652 if not isinstance(field, str)
or not field.strip():
8653 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
8656 f
" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
8659 reference_point = entry.get(
'reference_point', [1, 1, 1])
8660 if not isinstance(reference_point, (list, tuple))
or len(reference_point) != 3:
8662 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
8665 for rp_idx, coord
in enumerate(reference_point):
8668 except (TypeError, ValueError):
8670 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
8671 "must be integer-compatible."
8675 f
" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]. "
8676 f
"Available tasks: {list(POST_EULERIAN_PIPELINE_TASKS)}."
8680 lagrangian_pipeline = post_cfg.get(
'lagrangian_pipeline', [])
8681 if lagrangian_pipeline
is not None and not isinstance(lagrangian_pipeline, list):
8682 errors.append(f
" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
8683 lagrangian_pipeline = []
8684 for i, entry
in enumerate(lagrangian_pipeline):
8685 if not isinstance(entry, dict)
or 'task' not in entry:
8686 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
8688 task_name = entry.get(
'task')
8689 if task_name ==
'specific_ke':
8690 in_field = entry.get(
'input_field')
8691 out_field = entry.get(
'output_field')
8692 if not isinstance(in_field, str)
or not in_field.strip():
8693 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
8694 if not isinstance(out_field, str)
or not out_field.strip():
8695 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
8698 f
" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
8702 stats_cfg = post_cfg.get(
'statistics_pipeline')
8704 if stats_cfg
is not None:
8705 if isinstance(stats_cfg, list):
8706 stats_entries = stats_cfg
8707 elif isinstance(stats_cfg, dict):
8708 stats_entries = stats_cfg.get(
'tasks', [])
8709 if not isinstance(stats_entries, list):
8710 errors.append(f
" {post_path}: 'statistics_pipeline.tasks' must be a list.")
8711 stats_output_prefix = stats_cfg.get(
'output_prefix')
8712 if stats_output_prefix
is not None and not isinstance(stats_output_prefix, str):
8713 errors.append(f
" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
8716 f
" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
8718 for i, entry
in enumerate(stats_entries):
8719 if isinstance(entry, str):
8721 elif isinstance(entry, dict)
and 'task' in entry:
8722 task_name = entry.get(
'task')
8725 f
" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
8730 except ValueError
as e:
8731 errors.append(f
" {post_path}: {e}")
8733 legacy_stats_output_prefix = post_cfg.get(
'statistics_output_prefix')
8734 if legacy_stats_output_prefix
is not None and not isinstance(legacy_stats_output_prefix, str):
8735 errors.append(f
" {post_path}: 'statistics_output_prefix' must be a string when provided.")
8740 post_cfg, monitor_cfg, post_path)
8741 errors.extend(cadence_errors)
8742 warnings.extend(cadence_warnings)
8746 for warning
in warnings:
8747 print(f
"[WARN] {warning}", file=sys.stderr)
8751 @brief Validate Slurm scheduler configuration from cluster.yml.
8752 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
8753 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
8758 if not isinstance(cluster_cfg, dict)
or not cluster_cfg:
8759 errors.append(f
" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
8762 scheduler = cluster_cfg.get(
"scheduler", {})
8763 if not isinstance(scheduler, dict):
8764 errors.append(f
" {cluster_path}: 'scheduler' must be a mapping.")
8766 scheduler_type = scheduler.get(
"type",
"slurm")
8767 if str(scheduler_type).lower() !=
"slurm":
8768 errors.append(f
" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
8770 resources = cluster_cfg.get(
"resources", {})
8771 if not isinstance(resources, dict):
8772 errors.append(f
" {cluster_path}: 'resources' must be a mapping.")
8774 for req
in (
"account",
"nodes",
"ntasks_per_node",
"mem",
"time"):
8775 if req
not in resources:
8776 errors.append(f
" {cluster_path}: missing required key 'resources.{req}'.")
8777 for int_key
in (
"nodes",
"ntasks_per_node"):
8778 if int_key
in resources:
8779 val = resources.get(int_key)
8780 if not isinstance(val, int)
or val <= 0:
8781 errors.append(f
" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
8782 for str_key
in (
"account",
"mem",
"time",
"partition"):
8783 if str_key
in resources
and resources.get(str_key)
is not None:
8784 if not isinstance(resources.get(str_key), str):
8785 errors.append(f
" {cluster_path}: resources.{str_key} must be a string when provided.")
8786 if isinstance(resources.get(
"time"), str):
8789 except ValueError
as exc:
8791 f
" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
8793 account = resources.get(
"account")
8794 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
8796 f
"{cluster_path}: resources.account still uses the sample placeholder "
8797 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
8800 notifications = cluster_cfg.get(
"notifications", {})
8801 if notifications
is not None and not isinstance(notifications, dict):
8802 errors.append(f
" {cluster_path}: 'notifications' must be a mapping when provided.")
8803 elif isinstance(notifications, dict):
8804 mail_user = notifications.get(
"mail_user")
8806 errors.append(f
" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
8807 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
8809 f
"{cluster_path}: notifications.mail_user still uses the sample placeholder "
8810 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
8812 mail_type = notifications.get(
"mail_type")
8813 if mail_type
is not None and not isinstance(mail_type, str):
8814 errors.append(f
" {cluster_path}: notifications.mail_type must be a string when provided.")
8816 execution = cluster_cfg.get(
"execution", {})
8817 if execution
is not None and not isinstance(execution, dict):
8818 errors.append(f
" {cluster_path}: 'execution' must be a mapping when provided.")
8819 elif isinstance(execution, dict):
8820 module_setup = execution.get(
"module_setup", [])
8821 if module_setup
is not None and not isinstance(module_setup, list):
8822 errors.append(f
" {cluster_path}: execution.module_setup must be a list of shell lines.")
8823 elif isinstance(module_setup, list):
8824 for i, line
in enumerate(module_setup):
8825 if not isinstance(line, str):
8826 errors.append(f
" {cluster_path}: execution.module_setup[{i}] must be a string.")
8828 launcher = execution.get(
"launcher")
8829 if launcher
is not None and not isinstance(launcher, str):
8830 errors.append(f
" {cluster_path}: execution.launcher must be a string when provided.")
8831 launcher_args = execution.get(
"launcher_args")
8832 if launcher_args
is not None and not isinstance(launcher_args, list):
8833 errors.append(f
" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
8834 elif isinstance(launcher_args, list):
8835 for i, token
in enumerate(launcher_args):
8836 if not isinstance(token, (str, int, float)):
8837 errors.append(f
" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
8840 f
" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
8841 "split whitespace-separated arguments into separate list items."
8843 if (launcher
is None or isinstance(launcher, str))
and (launcher_args
is None or isinstance(launcher_args, list)):
8846 except ValueError
as exc:
8847 errors.append(f
" {cluster_path}: {exc}.")
8849 extra_sbatch = execution.get(
"extra_sbatch")
8850 if extra_sbatch
is not None and not isinstance(extra_sbatch, (dict, list)):
8851 errors.append(f
" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
8853 walltime_guard = execution.get(
"walltime_guard")
8854 if walltime_guard
is not None and not isinstance(walltime_guard, dict):
8855 errors.append(f
" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
8856 elif isinstance(walltime_guard, dict):
8857 enabled = walltime_guard.get(
"enabled")
8858 if enabled
is not None and not isinstance(enabled, bool):
8859 errors.append(f
" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
8861 warmup_steps = walltime_guard.get(
"warmup_steps")
8862 if warmup_steps
is not None and (
not isinstance(warmup_steps, int)
or isinstance(warmup_steps, bool)
or warmup_steps <= 0):
8864 f
" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
8867 multiplier = walltime_guard.get(
"multiplier")
8868 if multiplier
is not None:
8869 if isinstance(multiplier, bool)
or not isinstance(multiplier, (int, float))
or multiplier <= 0.0:
8871 f
" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
8873 elif float(multiplier) > 5.0:
8875 f
" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
8878 min_seconds = walltime_guard.get(
"min_seconds")
8879 if min_seconds
is not None and (
8880 isinstance(min_seconds, bool)
or not isinstance(min_seconds, (int, float))
or float(min_seconds) <= 0.0
8883 f
" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
8886 estimator_alpha = walltime_guard.get(
"estimator_alpha")
8887 if estimator_alpha
is not None:
8888 if isinstance(estimator_alpha, bool)
or not isinstance(estimator_alpha, (int, float)):
8890 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
8892 elif float(estimator_alpha) <= 0.0
or float(estimator_alpha) > 1.0:
8894 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
8898 for warning
in warnings:
8899 print(f
"[WARN] {warning}", file=sys.stderr)
8906 @brief Validate sweep/study specification from study.yml.
8907 @param[in] study_cfg Argument passed to `validate_study_config()`.
8908 @param[in] study_path Argument passed to `validate_study_config()`.
8909 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
8913 if not isinstance(study_cfg, dict)
or not study_cfg:
8914 errors.append(f
" {study_path}: study config is empty or not a valid YAML mapping.")
8917 base_cfgs = study_cfg.get(
"base_configs")
8918 if not isinstance(base_cfgs, dict):
8919 errors.append(f
" {study_path}: missing required mapping 'base_configs'.")
8921 for req
in (
"case",
"solver",
"monitor",
"post"):
8922 path_val = base_cfgs.get(req)
8923 if not path_val
or not isinstance(path_val, str):
8924 errors.append(f
" {study_path}: base_configs.{req} must be a path string.")
8925 elif not skip_base_file_check:
8927 if not os.path.isfile(resolved):
8928 errors.append(f
" {study_path}: base_configs.{req} does not exist: {resolved}")
8930 study_type = study_cfg.get(
"study_type")
8931 allowed_types = set(STUDY_TYPES)
8932 if study_type
not in allowed_types:
8934 f
" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
8937 parameters = study_cfg.get(
"parameters")
8938 parameter_sets = study_cfg.get(
"parameter_sets")
8939 allowed_roots = {
"case",
"solver",
"monitor",
"post"}
8940 if bool(parameters) == bool(parameter_sets):
8941 errors.append(f
" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
8942 elif parameter_sets:
8943 if not isinstance(parameter_sets, list)
or not parameter_sets:
8944 errors.append(f
" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
8946 for set_index, param_set
in enumerate(parameter_sets):
8947 if not isinstance(param_set, dict)
or not param_set:
8949 f
" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
8952 for key, value
in param_set.items():
8953 if not isinstance(key, str)
or "." not in key:
8955 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
8958 root = key.split(
".", 1)[0]
8959 if root
not in allowed_roots:
8961 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
8963 if isinstance(value, (dict, list)):
8965 f
" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
8968 if not isinstance(parameters, dict)
or not parameters:
8969 errors.append(f
" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
8971 for key, values
in parameters.items():
8972 if not isinstance(key, str)
or "." not in key:
8974 f
" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
8977 root = key.split(
".", 1)[0]
8978 if root
not in allowed_roots:
8980 f
" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
8982 if not isinstance(values, list)
or len(values) == 0:
8983 errors.append(f
" {study_path}: parameters.{key} must be a non-empty list.")
8985 metrics = study_cfg.get(
"metrics", [])
8986 if metrics
is not None and not isinstance(metrics, list):
8987 errors.append(f
" {study_path}: 'metrics' must be a list when provided.")
8988 elif isinstance(metrics, list):
8989 for i, metric
in enumerate(metrics):
8990 if isinstance(metric, str):
8992 if not isinstance(metric, dict):
8994 f
" {study_path}: metrics[{i}] must be a string or mapping."
8997 if "name" not in metric:
8998 errors.append(f
" {study_path}: metrics[{i}] missing required key 'name'.")
8999 if "source" not in metric:
9000 errors.append(f
" {study_path}: metrics[{i}] missing required key 'source'.")
9001 for label_key
in (
"plot_label",
"label",
"units"):
9002 label_value = metric.get(label_key)
9003 if label_value
is not None and (
not isinstance(label_value, str)
or not label_value.strip()):
9005 f
" {study_path}: metrics[{i}].{label_key} must be a non-empty string when provided."
9008 plotting = study_cfg.get(
"plotting", {})
9009 if plotting
is not None and not isinstance(plotting, dict):
9010 errors.append(f
" {study_path}: 'plotting' must be a mapping when provided.")
9011 elif isinstance(plotting, dict):
9012 enabled = plotting.get(
"enabled")
9013 if enabled
is not None and not isinstance(enabled, bool):
9014 errors.append(f
" {study_path}: plotting.enabled must be boolean when provided.")
9015 output_format = plotting.get(
"output_format")
9016 if output_format
is not None and output_format
not in STUDY_PLOT_FORMATS:
9017 errors.append(f
" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
9019 execution = study_cfg.get(
"execution", {})
9020 if execution
is not None and not isinstance(execution, dict):
9021 errors.append(f
" {study_path}: 'execution' must be a mapping when provided.")
9022 elif isinstance(execution, dict):
9023 max_conc = execution.get(
"max_concurrent_array_tasks")
9024 if max_conc
is not None and (
not isinstance(max_conc, int)
or max_conc <= 0):
9026 f
" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
9034 @brief Set nested dictionary value, creating intermediate maps when needed.
9035 @param[in] container Argument passed to `_deep_set()`.
9036 @param[in] dotted_path Argument passed to `_deep_set()`.
9037 @param[in] value Argument passed to `_deep_set()`.
9039 keys = dotted_path.split(
".")
9041 for key
in keys[:-1]:
9042 if key
not in current
or not isinstance(current[key], dict):
9044 current = current[key]
9045 current[keys[-1]] = value
9049 @brief Expand study parameter lists into cartesian-product combinations.
9050 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
9051 @return Value returned by `expand_parameter_matrix()`.
9053 param_keys =
list(parameters.keys())
9054 all_values = [parameters[k]
for k
in param_keys]
9056 for combo
in itertools.product(*all_values):
9057 combos.append(dict(zip(param_keys, combo)))
9063 @brief Expand either cartesian-study parameters or explicit parameter sets.
9064 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
9065 @return Value returned by `expand_study_parameter_combinations()`.
9067 parameter_sets = study_cfg.get(
"parameter_sets")
9069 return [dict(param_set)
for param_set
in parameter_sets]
9075 @brief Flatten grouped study overrides into scalar dotted-path columns.
9077 @details A grouped override such as `case.run_control: {dt_physical: ...}`
9078 materializes correctly in case YAML but is not a useful CSV cell or
9079 plot coordinate. Flattening preserves the actual varied variables.
9081 @param[in] parameters One expanded study parameter combination.
9082 @return Flat dotted-path-to-scalar mapping.
9086 def visit(prefix, value):
9088 @brief Recursively flatten one grouped override value.
9089 @param[in] prefix Current dotted parameter path.
9090 @param[in] value Scalar or nested mapping at the current path.
9092 if isinstance(value, dict):
9093 for child, child_value
in value.items():
9094 visit(f
"{prefix}.{child}" if prefix
else str(child), child_value)
9096 flattened[prefix] = value
9098 for key, value
in (parameters
or {}).items():
9099 visit(str(key), value)
9105 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
9106 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
9107 @return Value returned by `get_study_parameter_keys()`.
9109 parameters = study_cfg.get(
"parameters")
9110 if isinstance(parameters, dict)
and parameters:
9112 for key, candidates
in parameters.items():
9113 candidate_dicts = [value
for value
in candidates
if isinstance(value, dict)]
if isinstance(candidates, list)
else []
9115 for value
in candidate_dicts:
9117 if flat_key
not in expanded:
9118 expanded.append(flat_key)
9119 for flat_key
in expanded
or [key]:
9120 if flat_key
not in keys:
9121 keys.append(flat_key)
9125 parameter_sets = study_cfg.get(
"parameter_sets")
or []
9126 for param_set
in parameter_sets:
9127 if not isinstance(param_set, dict):
9137 @brief Return cluster total tasks.
9138 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
9139 @return Value returned by `get_cluster_total_tasks()`.
9141 resources = cluster_cfg.get(
"resources", {})
9142 return int(resources.get(
"nodes", 1)) * int(resources.get(
"ntasks_per_node", 1))
9146 @brief Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
9147 @param[in] ext Argument passed to `normalize_extension()`.
9148 @return Value returned by `normalize_extension()`.
9152 return str(ext).strip().lstrip(
".")
9156 @brief Resolve how to invoke this conductor again from a batch script.
9158 @details Prefers the `bin/picurv` wrapper, which selects the managed Python
9159 environment a cluster node needs, and falls back to the running
9160 interpreter with the package entry point when that wrapper is absent.
9162 @return Argv prefix that re-invokes the conductor.
9164 wrapper = os.path.join(PACKAGE_PROJECT_ROOT,
"bin",
"picurv")
9165 if os.path.isfile(wrapper)
and os.access(wrapper, os.X_OK):
9167 return [sys.executable, os.path.join(PACKAGE_PROJECT_ROOT,
"picurv_cli",
"picurv")]
9172 @brief Build the batch-script step that measures spectra after the field stage.
9174 @details Spectra are a serial pass over committed checkpoints, so the command is
9175 returned bare rather than wrapped in the MPI launcher: run under `srun`
9176 with the post stage's task count it would become one identical copy per
9177 task, each writing the same files.
9179 @param[in] run_dir Run directory the batch job operates on.
9180 @param[in] post_path Post recipe path, reachable from the compute node.
9181 @param[in] post_cfg Effective post configuration.
9182 @return Argv list, or an empty list when the recipe requests no spectra.
9189 if not spectra[
"tasks"]:
9192 "run",
"--post-process",
"--only",
"spectra",
9193 "--run-dir", os.path.abspath(run_dir),
9194 "--post", os.path.abspath(post_path),
9205 stderr_path: str =
None,
9206 env_vars: dict =
None,
9207 shell_env_vars: dict =
None,
9208 array_spec: str =
None,
9209 follow_commands: list =
None
9212 @brief Render a Slurm batch script for a single command.
9213 @param[in] script_path Argument passed to `render_slurm_script()`.
9214 @param[in] job_name Argument passed to `render_slurm_script()`.
9215 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
9216 @param[in] command Argument passed to `render_slurm_script()`.
9217 @param[in] workdir Argument passed to `render_slurm_script()`.
9218 @param[in] stdout_path Argument passed to `render_slurm_script()`.
9219 @param[in] stderr_path Argument passed to `render_slurm_script()`.
9220 @param[in] env_vars Argument passed to `render_slurm_script()`.
9221 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
9222 @param[in] array_spec Argument passed to `render_slurm_script()`.
9223 @param[in] follow_commands Commands to run after the launched one, each an argv
9224 list. They run in the batch shell rather than under the
9225 MPI launcher, so a serial step does not become one copy
9226 per task. Supplying any of them drops the `exec`.
9228 resources = cluster_cfg.get(
"resources", {})
9229 notifications = cluster_cfg.get(
"notifications", {})
or {}
9230 execution = cluster_cfg.get(
"execution", {})
or {}
9231 extra_sbatch = execution.get(
"extra_sbatch")
9232 module_setup = execution.get(
"module_setup", [])
or []
9234 if stderr_path
is None:
9235 stderr_path = stdout_path.replace(
".out",
".err")
9239 f
"#SBATCH --job-name={job_name}",
9240 f
"#SBATCH --nodes={resources['nodes']}",
9241 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
9242 f
"#SBATCH --mem={resources['mem']}",
9243 f
"#SBATCH --time={resources['time']}",
9244 f
"#SBATCH --output={stdout_path}",
9245 f
"#SBATCH --error={stderr_path}",
9246 f
"#SBATCH --account={resources['account']}",
9248 partition = resources.get(
"partition")
9250 lines.append(f
"#SBATCH --partition={partition}")
9252 lines.append(f
"#SBATCH --array={array_spec}")
9253 mail_user = notifications.get(
"mail_user")
9254 mail_type = notifications.get(
"mail_type")
9256 lines.append(f
"#SBATCH --mail-user={mail_user}")
9258 lines.append(f
"#SBATCH --mail-type={mail_type}")
9260 if isinstance(extra_sbatch, dict):
9261 for key, value
in extra_sbatch.items():
9263 if not flag.startswith(
"--"):
9265 if isinstance(value, bool):
9267 lines.append(f
"#SBATCH {flag}")
9268 elif value
is not None:
9269 lines.append(f
"#SBATCH {flag}={value}")
9270 elif isinstance(extra_sbatch, list):
9271 for token
in extra_sbatch:
9272 lines.append(f
"#SBATCH {token}")
9277 "set -euo pipefail",
9279 f
"cd {shlex.quote(workdir)}",
9280 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
9281 'echo "[$(date)] Working directory: $PWD"',
9286 for key, value
in shell_env_vars.items():
9287 lines.append(f
"export {key}={value}")
9289 for setup_line
in module_setup:
9290 lines.append(str(setup_line))
9293 for key, value
in env_vars.items():
9294 lines.append(f
"export {key}={shlex.quote(str(value))}")
9296 cmd =
" ".join(shlex.quote(str(tok))
for tok
in command)
9302 for follow
in follow_commands:
9303 lines.append(
" ".join(shlex.quote(str(tok))
for tok
in follow))
9305 lines.append(f
"exec {cmd}")
9307 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
9308 with open(script_path,
"w")
as f:
9309 f.write(
"\n".join(lines) +
"\n")
9310 os.chmod(script_path, 0o755)
9313 launcher:
"str | None",
9314 launcher_args:
"list | None" =
None,
9315 label: str =
"launcher",
9316) ->
"tuple[str | None, list[str]]":
9318 @brief Canonicalize launcher config into executable token plus argv-style flags.
9319 @param[in] launcher Argument passed to `split_launcher_tokens()`.
9320 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
9321 @param[in] label Argument passed to `split_launcher_tokens()`.
9322 @return Value returned by `split_launcher_tokens()`.
9324 normalized_args = [str(x)
for x
in (launcher_args
or [])]
9326 if launcher
is None:
9327 return None, normalized_args
9330 launcher_tokens = shlex.split(str(launcher))
9331 except ValueError
as exc:
9332 raise ValueError(f
"{label} is not shell-parseable: {exc}")
from exc
9334 if not launcher_tokens:
9335 return None, normalized_args
9337 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
9342 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
9343 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
9344 @return Value returned by `normalize_cluster_launcher()`.
9347 execution.get(
"launcher"),
9348 execution.get(
"launcher_args")
or [],
9349 label=
"execution.launcher",
9355 @brief Remove explicit MPI task-count flags from known launchers.
9356 @param[in] launcher_name Basename-normalized launcher executable.
9357 @param[in] launcher_args Launcher argument list.
9358 @return Filtered launcher arguments with explicit size flags removed.
9362 while idx < len(launcher_args):
9363 token = str(launcher_args[idx])
9365 if launcher_name ==
"srun":
9366 if token
in {
"-n",
"--ntasks"}:
9369 if token.startswith(
"--ntasks="):
9372 elif launcher_name
in {
"mpiexec",
"mpirun"}:
9373 if token
in {
"-n",
"-np"}:
9376 if token.startswith(
"-n=")
or token.startswith(
"-np="):
9380 filtered.append(token)
9388 executable_args: list,
9390 config_search_anchor: str =
None,
9391 allow_single_rank_launcher_override: bool =
False,
9392 force_num_procs:
"int | None" =
None,
9395 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
9396 @param[in] executable Argument passed to `build_local_launch_command()`.
9397 @param[in] executable_args Argument passed to `build_local_launch_command()`.
9398 @param[in] num_procs Argument passed to `build_local_launch_command()`.
9399 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
9400 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
9401 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
9402 @return Value returned by `build_local_launch_command()`.
9404 target_num_procs = force_num_procs
if force_num_procs
is not None else num_procs
9405 command = [executable] + executable_args
9406 if target_num_procs <= 1
and not allow_single_rank_launcher_override:
9409 launcher_override = os.environ.get(
"PICURV_MPI_LAUNCHER")
9410 if launcher_override
is None:
9411 launcher_override = os.environ.get(
"MPI_LAUNCHER")
9414 if launcher_override
is not None:
9415 explicit_launcher_config =
True
9418 label=
"local MPI launcher override",
9423 configured_launcher = local_execution.get(
"launcher")
9424 configured_args = local_execution.get(
"launcher_args")
or []
9425 explicit_launcher_config = configured_launcher
is not None or bool(configured_args)
9426 if target_num_procs <= 1
and not explicit_launcher_config:
9429 configured_launcher
if configured_launcher
is not None else "mpiexec",
9431 label=
"local_execution.launcher",
9433 except ValueError
as exc:
9434 print(f
"[FATAL] {exc}", file=sys.stderr)
9440 launcher_name = os.path.basename(launcher).lower()
9441 if force_num_procs
is not None:
9443 prefix = [launcher] + launcher_args
9445 if launcher_name ==
"srun":
9446 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
9448 prefix += [
"-n", str(target_num_procs)]
9449 elif launcher_name
in {
"mpiexec",
"mpirun"}:
9450 has_n = any(token
in {
"-n",
"-np"}
for token
in launcher_args)
9452 prefix += [
"-n", str(target_num_procs)]
9454 return prefix + command
9458 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
9459 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
9460 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
9461 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
9462 @return Value returned by `resolve_cluster_execution()`.
9466 execution = cluster_cfg.get(
"execution", {})
or {}
9467 cluster_override = {
9468 "launcher": execution.get(
"launcher")
if "launcher" in execution
else None,
9469 "launcher_args": execution.get(
"launcher_args")
if "launcher_args" in execution
else None,
9477 executable_args: list,
9478 config_search_anchor: str =
None,
9479 extra_search_anchors=
None,
9480 force_num_procs:
"int | None" =
None,
9483 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
9484 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
9485 @param[in] executable Argument passed to `build_cluster_launch_command()`.
9486 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
9487 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
9488 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
9489 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
9490 @return Value returned by `build_cluster_launch_command()`.
9495 config_search_anchor=config_search_anchor,
9496 extra_search_anchors=extra_search_anchors,
9499 execution.get(
"launcher")
if execution.get(
"launcher")
is not None else "srun",
9500 execution.get(
"launcher_args")
or [],
9501 label=
"cluster execution launcher",
9503 except ValueError
as exc:
9504 print(f
"[FATAL] {exc}", file=sys.stderr)
9508 launcher_name = launcher.lower()
if launcher
else ""
9509 if force_num_procs
is not None:
9512 if launcher
and launcher_name ==
"srun":
9513 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
9514 cmd = [
"srun"] + launcher_args
9516 cmd += [
"-n", str(ntasks)]
9517 return cmd + [executable] + executable_args
9519 if launcher
and launcher_name ==
"mpirun":
9520 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
9521 cmd = [
"mpirun"] + launcher_args
9523 cmd += [
"-np", str(ntasks)]
9524 return cmd + [executable] + executable_args
9526 if launcher
and launcher_name ==
"mpiexec":
9527 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
9528 cmd = [
"mpiexec"] + launcher_args
9530 cmd += [
"-np", str(ntasks)]
9531 return cmd + [executable] + executable_args
9536 cmd.append(str(launcher))
9537 cmd += launcher_args
9538 cmd += [executable] + executable_args
9543 @brief Extract numeric job id from standard sbatch output.
9544 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
9545 @return Value returned by `parse_slurm_job_id()`.
9547 match = re.search(
r"Submitted batch job\s+(\d+)", sbatch_output
or "")
9548 return match.group(1)
if match
else None
9550def submit_sbatch(script_path: str, dependency: str =
None, dependency_type: str =
"afterok") -> dict:
9552 @brief Submit sbatch script and return submission metadata.
9553 @param[in] script_path Argument passed to `submit_sbatch()`.
9554 @param[in] dependency Argument passed to `submit_sbatch()`.
9555 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
9556 @return Value returned by `submit_sbatch()`.
9560 cmd.append(f
"--dependency={dependency_type}:{dependency}")
9561 cmd.append(script_path)
9562 result = subprocess.run(cmd, text=
True, capture_output=
True, check=
False)
9565 "returncode": result.returncode,
9566 "stdout": (result.stdout
or "").strip(),
9567 "stderr": (result.stderr
or "").strip(),
9568 "script": script_path,
9570 if result.returncode != 0:
9571 print(f
"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
9572 sys.exit(result.returncode)
9574 if not metadata[
"job_id"]:
9576 f
"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
9585 @brief Prints validation errors and exits.
9586 @param[in] errors List of error message strings.
9588 print(f
"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
9589 for raw_error
in errors:
9595 "\nHint: See examples/master_template/ for valid config structure and "
9596 "docs/pages/14_Config_Contract.md for key-level contract details.",
9604 @brief Creates a standard header block for all generated files.
9605 @param[in] run_id The unique identifier for the current simulation run.
9606 @param[in] source_files A dictionary of source profile files used.
9607 @return A formatted string containing the header.
9610 "# ==============================================================================",
9611 "# AUTO-GENERATED CONFIGURATION FILE",
9612 "# ------------------------------------------------------------------------------",
9613 f
"# Run ID: {run_id}",
9614 f
"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
9616 "# Source Configuration:"
9618 for name, path
in source_files.items():
9619 header_parts.append(f
"# - {name:<12}: {os.path.basename(path)}")
9620 header_parts.extend([
9622 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
9623 "# ==============================================================================\n"
9625 return "\n".join(header_parts)
9628 filename: str, header_sources: dict,
9629 config_dir: str =
None) -> str:
9631 @brief Generic function to create a file containing a simple list of strings.
9632 @param[in] run_dir The path to the main run directory.
9633 @param[in] run_id The unique identifier for the run.
9634 @param[in] cfg The dictionary containing the configuration data.
9635 @param[in] section The top-level key in the cfg dictionary.
9636 @param[in] key The second-level key whose value is the list of strings.
9637 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
9638 @param[in] header_sources A dictionary of source files for the header.
9639 @param[in] config_dir Optional configuration revision directory.
9640 @return The absolute path to the generated file.
9642 print(f
"[INFO] Generating {filename}...")
9643 config_dir = config_dir
or os.path.join(run_dir,
"config")
9644 os.makedirs(config_dir, exist_ok=
True)
9645 file_path = os.path.join(config_dir, filename)
9648 items = cfg.get(section, {}).get(key, [])
9651 with open(file_path,
"w")
as f: f.write(
"\n".join(lines))
9652 print(f
"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
9653 return os.path.abspath(file_path)
9658 @brief Return True when logging.enabled_functions contains at least one entry.
9659 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
9660 @return Value returned by `has_explicit_monitor_whitelist()`.
9662 items = monitor_cfg.get(
"logging", {}).get(
"enabled_functions", [])
9668 @brief Resolve profiling reporting config from monitor.yml.
9669 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
9670 @return Value returned by `resolve_profiling_config()`.
9672 profiling_cfg = monitor_cfg.get(
"profiling", {})
or {}
9673 timestep_cfg = profiling_cfg.get(
"timestep_output")
9674 final_cfg = profiling_cfg.get(
"final_summary")
9676 if timestep_cfg
is None:
9679 timestep_file =
"Profiling_Timestep_Summary.csv"
9681 if not isinstance(timestep_cfg, dict):
9682 raise ValueError(
"monitor.profiling.timestep_output must be a mapping when provided.")
9683 mode = str(timestep_cfg.get(
"mode",
"off")).lower()
9684 functions = timestep_cfg.get(
"functions", [])
9685 timestep_file = str(timestep_cfg.get(
"file",
"Profiling_Timestep_Summary.csv"))
9687 if mode
not in PROFILING_TIMESTEP_MODES:
9688 raise ValueError(
"monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
9689 if functions
is None:
9691 if not isinstance(functions, list):
9692 raise ValueError(
"monitor.profiling.timestep_output.functions must be a list of function names.")
9693 if not all(isinstance(item, str)
and item.strip()
for item
in functions):
9694 raise ValueError(
"monitor.profiling.timestep_output.functions entries must be non-empty strings.")
9695 if mode ==
"selected" and not functions:
9696 raise ValueError(
"monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
9697 if mode !=
"selected" and functions:
9698 raise ValueError(
"monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
9699 if not timestep_file:
9700 raise ValueError(
"monitor.profiling.timestep_output.file must be a non-empty string.")
9702 if final_cfg
is None:
9703 final_enabled =
True
9704 elif isinstance(final_cfg, dict):
9705 final_enabled = bool(final_cfg.get(
"enabled",
True))
9707 raise ValueError(
"monitor.profiling.final_summary must be a mapping when provided.")
9711 "functions": functions,
9712 "timestep_file": timestep_file,
9713 "final_summary_enabled": final_enabled,
9717DIAGNOSTICS_PETSC_KEYS = {
9723 "malloc_view_threshold",
9736 @brief Validate PETSc info logging configuration.
9737 @param[in] value Boolean or structured PETSc info configuration.
9738 @return Normalized enabled/class-filter mapping.
9740 if isinstance(value, bool):
9741 return {
"enabled": value,
"classes": []}
9743 return {
"enabled":
False,
"classes": []}
9744 if not isinstance(value, dict):
9745 raise ValueError(
"monitor.diagnostics.petsc.info must be boolean, null, or a mapping.")
9746 unknown = sorted(set(value) - {
"enabled",
"classes"})
9748 raise ValueError(f
"monitor.diagnostics.petsc.info has unsupported key(s): {unknown}.")
9749 enabled = value.get(
"enabled",
True)
9750 classes = value.get(
"classes", [])
9751 if not isinstance(enabled, bool):
9752 raise ValueError(
"monitor.diagnostics.petsc.info.enabled must be boolean.")
9753 if not isinstance(classes, list)
or not all(
9754 isinstance(item, str)
and item.strip()
and "," not in item
and ":" not in item
9758 "monitor.diagnostics.petsc.info.classes must be a list of non-empty PETSc class names."
9760 return {
"enabled": enabled,
"classes": [item.strip()
for item
in classes]}
9765 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
9766 @param[in] value Candidate value.
9767 @param[in] key Diagnostics key used in error messages.
9768 @return Normalized value.
9770 if isinstance(value, bool)
or value
is None:
9772 if isinstance(value, str)
and value.strip():
9773 return value.strip()
9774 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
9779 @brief Validate a diagnostics boolean value.
9780 @param[in] value Candidate value.
9781 @param[in] key Diagnostics key used in error messages.
9782 @return Boolean value.
9784 if isinstance(value, bool):
9786 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean.")
9791 @brief Validate a diagnostics value that can be false, true, or "all".
9792 @param[in] value Candidate value.
9793 @param[in] key Diagnostics key used in error messages.
9794 @return Normalized value.
9796 if isinstance(value, bool)
or value
is None:
9798 if isinstance(value, str)
and value.strip().lower() ==
"all":
9800 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
9805 @brief Return an absolute run-local diagnostics file path.
9806 @param[in] run_dir Run directory.
9807 @param[in] filename Diagnostics filename.
9808 @return Absolute diagnostics path under the run logs directory.
9810 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS[
"logs"], filename))
9815 @brief Resolve true/string diagnostics values to a concrete file path.
9816 @param[in] value Boolean/string diagnostics value.
9817 @param[in] run_dir Run directory.
9818 @param[in] default_filename Default file name when value is true.
9819 @return False, or an absolute/explicit path string.
9823 if isinstance(value, str):
9824 if os.path.isabs(value)
or value.startswith(
":"):
9826 return os.path.abspath(os.path.join(run_dir,
"logs", value))
9832 @brief Resolve monitor diagnostics config and default run-local log paths.
9833 @param[in] monitor_cfg Parsed monitor.yml mapping.
9834 @param[in] run_dir Optional run directory for default artifact paths.
9835 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
9836 @return Normalized diagnostics config.
9838 diagnostics_cfg = (monitor_cfg.get(
"diagnostics", {})
or {})
if isinstance(monitor_cfg, dict)
else {}
9839 if not isinstance(diagnostics_cfg, dict):
9840 raise ValueError(
"monitor.diagnostics must be a mapping when provided.")
9842 petsc_raw = diagnostics_cfg.get(
"petsc", {})
or {}
9843 if not isinstance(petsc_raw, dict):
9844 raise ValueError(
"monitor.diagnostics.petsc must be a mapping when provided.")
9845 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
9847 raise ValueError(f
"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
9851 "malloc_debug":
_diagnostic_bool(petsc_raw.get(
"malloc_debug",
False),
"malloc_debug"),
9852 "malloc_test":
_diagnostic_bool(petsc_raw.get(
"malloc_test",
False),
"malloc_test"),
9853 "malloc_dump":
_diagnostic_bool(petsc_raw.get(
"malloc_dump",
False),
"malloc_dump"),
9855 "malloc_view_threshold": petsc_raw.get(
"malloc_view_threshold"),
9856 "memory_view":
_diagnostic_bool(petsc_raw.get(
"memory_view",
False),
"memory_view"),
9858 "log_view_memory":
_diagnostic_bool(petsc_raw.get(
"log_view_memory",
False),
"log_view_memory"),
9862 "options_left": petsc_raw.get(
"options_left"),
9864 if petsc[
"malloc_view_threshold"]
is not None and not isinstance(petsc[
"malloc_view_threshold"], (int, float)):
9865 raise ValueError(
"monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
9866 if petsc[
"options_left"]
is not None and not isinstance(petsc[
"options_left"], bool):
9867 raise ValueError(
"monitor.diagnostics.petsc.options_left must be boolean or null.")
9869 memory_raw = diagnostics_cfg.get(
"runtime_memory_log", {})
or {}
9870 if not isinstance(memory_raw, dict):
9871 raise ValueError(
"monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
9872 memory_unknown = sorted(set(memory_raw.keys()) - {
"enabled",
"file"})
9874 raise ValueError(f
"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
9875 memory_enabled = memory_raw.get(
"enabled",
True)
9876 if not isinstance(memory_enabled, bool):
9877 raise ValueError(
"monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
9878 memory_file = str(memory_raw.get(
"file",
"Runtime_Memory.log")).strip()
9880 raise ValueError(
"monitor.diagnostics.runtime_memory_log.file must be a non-empty string.")
9882 resolved_petsc = dict(petsc)
9885 suffix =
"PostProcessor" if stage_label ==
"PostProcessor" else "Solver"
9886 resolved_petsc[
"info"] =
False
9888 "malloc_view": f
"PETSc_MallocView_{suffix}.log",
9889 "log_view": f
"PETSc_LogView_{suffix}.log",
9890 "log_trace": f
"PETSc_LogTrace_{suffix}.log",
9892 if petsc[
"info"][
"enabled"]:
9894 classes = petsc[
"info"][
"classes"]
9895 resolved_petsc[
"info"] = info_path + (f
":{','.join(classes)}" if classes
else "")
9897 artifacts.append(f
"{info_path}.*")
9898 for key, default_name
in defaults.items():
9900 if key ==
"log_view" and resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
9901 resolved_value = f
":{resolved_value}"
9902 resolved_petsc[key] = resolved_value
9903 if resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
9904 artifacts.append(resolved_value)
9905 elif resolved_value
and isinstance(resolved_value, str)
and resolved_value.startswith(
":"):
9906 artifacts.append(resolved_value[1:])
9909 os.path.abspath(os.path.join(
9910 run_dir, CANONICAL_RUN_PATHS[
"logs"], memory_file
9915 "petsc": resolved_petsc,
9916 "runtime_memory_log": {
"enabled": memory_enabled,
"file": memory_file},
9917 "artifacts": artifacts,
9923 @brief Build PETSc diagnostics command-line arguments for a run stage.
9924 @param[in] monitor_cfg Parsed monitor.yml mapping.
9925 @param[in] run_dir Run directory used to resolve default diagnostics files.
9926 @param[in] stage_label Stage label for default output names.
9927 @return List of executable arguments.
9930 petsc = diagnostics[
"petsc"]
9933 args.extend([
"-info", str(petsc[
"info"])])
9934 if petsc[
"malloc_debug"]:
9935 args.append(
"-malloc_debug")
9936 if petsc[
"malloc_test"]:
9937 args.append(
"-malloc_test")
9939 (
"malloc_dump",
"-malloc_dump"),
9940 (
"malloc_view",
"-malloc_view"),
9941 (
"memory_view",
"-memory_view"),
9942 (
"log_view",
"-log_view"),
9943 (
"log_trace",
"-log_trace"),
9944 (
"objects_dump",
"-objects_dump"),
9946 value = petsc.get(key)
9950 args.extend([flag, str(value)])
9951 if petsc[
"malloc_view_threshold"]
is not None:
9952 args.extend([
"-malloc_view_threshold", str(petsc[
"malloc_view_threshold"])])
9953 if petsc[
"log_view_memory"]:
9954 args.append(
"-log_view_memory")
9955 if petsc[
"log_all"]:
9956 args.append(
"-log_all")
9957 if petsc[
"options_left"]
is not None:
9958 args.extend([
"-options_left",
"true" if petsc[
"options_left"]
else "false"])
9964 @brief Report whether the subsystem a statistics field depends on is active.
9965 @param[in] case_cfg Parsed case configuration.
9966 @param[in] requirement Subsystem key from `STATISTICS_ELIGIBLE_FIELDS`, or None.
9967 @return Value returned by `_statistics_subsystem_available()`.
9969 if requirement
is None:
9971 physics = ((case_cfg
or {}).get(
"models", {})
or {}).get(
"physics", {})
or {}
9972 if requirement ==
"particles":
9973 particles = physics.get(
"particles", {})
or {}
9975 return int(particles.get(
"count", 0)
or 0) > 0
9976 except (TypeError, ValueError):
9978 turbulence = physics.get(
"turbulence", {})
or {}
9979 les_on = bool((turbulence.get(
"les", {})
or {}).get(
"enabled",
False))
9980 rans_on = bool((turbulence.get(
"rans", {})
or {}).get(
"enabled",
False))
9981 if requirement ==
"les":
9983 if requirement ==
"turbulence":
9984 return les_on
or rans_on
9990 @brief Validate and canonicalize the field-statistics block of monitor.yml.
9992 @details Rejects every condition the field-statistics contract forbids, naming the
9993 offending window so a message points at one entry rather than the block.
9994 Returns a canonical form the flag resolver serializes without further
9995 interpretation, so validation and emission cannot disagree.
9997 @param[in] monitor_cfg Parsed monitor configuration.
9998 @param[in] case_cfg Parsed case configuration, used to check that each field's
9999 subsystem is active. Subsystem checks are skipped when None.
10000 @return Value returned by `normalize_field_statistics_config()`.
10002 raw = (monitor_cfg
or {}).get(
"field_statistics")
10004 return {
"enabled":
False,
"windows": []}
10005 if not isinstance(raw, dict):
10006 raise ValueError(
"'field_statistics' must be a mapping.")
10008 enabled = raw.get(
"enabled",
False)
10009 if not isinstance(enabled, bool):
10010 raise ValueError(
"'field_statistics.enabled' must be true or false.")
10011 windows_raw = raw.get(
"windows", [])
or []
10012 if not isinstance(windows_raw, list):
10013 raise ValueError(
"'field_statistics.windows' must be a list.")
10014 if enabled
and not windows_raw:
10015 raise ValueError(
"'field_statistics.enabled' is true but no window is defined.")
10019 for index, window
in enumerate(windows_raw):
10020 if not isinstance(window, dict):
10021 raise ValueError(f
"'field_statistics.windows[{index}]' must be a mapping.")
10022 name = window.get(
"name")
10023 if not isinstance(name, str)
or not name.strip():
10024 raise ValueError(f
"'field_statistics.windows[{index}]' needs a non-empty 'name'.")
10025 name = name.strip()
10028 if name
in seen_names:
10029 raise ValueError(f
"field statistics window '{name}' is defined more than once.")
10030 seen_names.add(name)
10032 start_time = window.get(
"start_time")
10033 if not isinstance(start_time, (int, float))
or isinstance(start_time, bool):
10034 raise ValueError(f
"field statistics window '{name}': 'start_time' must be a number.")
10035 end_time = window.get(
"end_time")
10036 if end_time
is not None:
10037 if not isinstance(end_time, (int, float))
or isinstance(end_time, bool):
10038 raise ValueError(f
"field statistics window '{name}': 'end_time' must be a number.")
10039 if float(end_time) <= float(start_time):
10041 f
"field statistics window '{name}': 'end_time' ({end_time}) must be greater "
10042 f
"than 'start_time' ({start_time})."
10045 weighting = window.get(
"weighting")
10046 if weighting
not in STATISTICS_WEIGHTING_MODES:
10048 f
"field statistics window '{name}': 'weighting' must be one of "
10049 f
"{list(STATISTICS_WEIGHTING_MODES)} (got {weighting!r})."
10052 has_step =
"step_cadence" in window
and window[
"step_cadence"]
is not None
10053 has_time =
"time_cadence" in window
and window[
"time_cadence"]
is not None
10054 if has_step == has_time:
10056 f
"field statistics window '{name}': set exactly one of 'step_cadence' and "
10059 step_cadence =
None
10060 time_cadence =
None
10062 step_cadence = window[
"step_cadence"]
10063 if not isinstance(step_cadence, int)
or isinstance(step_cadence, bool)
or step_cadence <= 0:
10065 f
"field statistics window '{name}': 'step_cadence' must be a positive integer "
10066 f
"(got {step_cadence!r})."
10069 time_cadence = window[
"time_cadence"]
10070 if (
not isinstance(time_cadence, (int, float))
or isinstance(time_cadence, bool)
10071 or float(time_cadence) <= 0.0):
10073 f
"field statistics window '{name}': 'time_cadence' must be a positive number "
10074 f
"(got {time_cadence!r})."
10077 fields_raw = window.get(
"fields")
10078 if not isinstance(fields_raw, list)
or not fields_raw:
10079 raise ValueError(f
"field statistics window '{name}': 'fields' must be a non-empty list.")
10081 for field_entry
in fields_raw:
10082 if not isinstance(field_entry, dict):
10083 raise ValueError(f
"field statistics window '{name}': each 'fields' entry must be a mapping.")
10084 field_name = field_entry.get(
"field")
10085 if field_name
not in STATISTICS_ELIGIBLE_FIELDS:
10087 f
"field statistics window '{name}': field {field_name!r} cannot be accumulated. "
10088 f
"Available fields: {sorted(STATISTICS_ELIGIBLE_FIELDS)}."
10090 requirement = STATISTICS_ELIGIBLE_FIELDS[field_name][
"requires"]
10093 f
"field statistics window '{name}': field '{field_name}' requires the "
10094 f
"'{requirement}' subsystem, which is not enabled for this case."
10096 if any(existing[
"field"] == field_name
for existing
in fields):
10098 f
"field statistics window '{name}': field '{field_name}' is listed more than once."
10100 moments = field_entry.get(
"moments")
10101 if not isinstance(moments, list)
or not moments:
10103 f
"field statistics window '{name}': field '{field_name}' needs a non-empty "
10106 unknown = [m
for m
in moments
if m
not in STATISTICS_MOMENT_NAMES]
10109 f
"field statistics window '{name}': field '{field_name}' requests unknown "
10110 f
"moments {unknown}. Available moments: {list(STATISTICS_MOMENT_NAMES)}."
10114 fields.append({
"field": field_name,
"moments": [
"first"] + ([
"second"]
if "second" in moments
else [])})
10116 covariances_raw = window.get(
"covariances", [])
or []
10117 if not isinstance(covariances_raw, list):
10118 raise ValueError(f
"field statistics window '{name}': 'covariances' must be a list.")
10119 requested = {entry[
"field"]
for entry
in fields}
10121 for pair
in covariances_raw:
10122 if not isinstance(pair, list)
or len(pair) != 2:
10124 f
"field statistics window '{name}': each covariance must be a pair of field names."
10126 first, second = pair
10127 for member
in (first, second):
10128 if member
not in STATISTICS_ELIGIBLE_FIELDS:
10130 f
"field statistics window '{name}': covariance member {member!r} cannot be "
10131 f
"accumulated. Available fields: {sorted(STATISTICS_ELIGIBLE_FIELDS)}."
10133 if first == second:
10135 f
"field statistics window '{name}': covariance ['{first}', '{second}'] pairs a "
10136 "field with itself; request that through moments: [second] instead."
10138 missing = sorted({first, second} - requested)
10141 f
"field statistics window '{name}': covariance ['{first}', '{second}'] needs "
10142 f
"{missing} in 'fields' as well, because a co-moment is centered against their means."
10146 if (STATISTICS_ELIGIBLE_FIELDS[first][
"components"] == 3
10147 and STATISTICS_ELIGIBLE_FIELDS[second][
"components"] == 3):
10149 f
"field statistics window '{name}': covariance ['{first}', '{second}'] pairs two "
10150 "vector fields, which is not supported."
10152 if sorted((first, second))
in [sorted(existing)
for existing
in covariances]:
10154 f
"field statistics window '{name}': covariance ['{first}', '{second}'] is "
10155 "requested more than once."
10157 covariances.append([first, second])
10161 "start_time": float(start_time),
10162 "end_time":
None if end_time
is None else float(end_time),
10163 "weighting": weighting,
10164 "step_cadence": step_cadence,
10165 "time_cadence":
None if time_cadence
is None else float(time_cadence),
10167 "covariances": covariances,
10170 return {
"enabled": bool(enabled),
"windows": windows}
10175 @brief Serialize field-statistics configuration into control-file option lines.
10176 @details A window list is variable arity, so its option names are constructed from
10177 the index. Each name belongs to a family declared in the ingress audit
10179 @param[in] monitor_cfg Parsed monitor configuration.
10180 @param[in] case_cfg Parsed case configuration, for subsystem availability checks.
10181 @return Value returned by `resolve_field_statistics_flags()`.
10184 if not config[
"enabled"]:
10187 lines = [
"-field_statistics_enabled true",
10188 f
"-field_statistics_window_count {len(config['windows'])}"]
10189 for index, window
in enumerate(config[
"windows"]):
10190 prefix = f
"-field_statistics_window_{index}"
10191 lines.append(f
"{prefix}_name {window['name']}")
10192 lines.append(f
"{prefix}_start_time {window['start_time']!r}")
10195 if window[
"end_time"]
is not None:
10196 lines.append(f
"{prefix}_end_time {window['end_time']!r}")
10197 lines.append(f
"{prefix}_weighting {window['weighting']}")
10198 if window[
"step_cadence"]
is not None:
10199 lines.append(f
"{prefix}_step_cadence {window['step_cadence']}")
10201 lines.append(f
"{prefix}_time_cadence {window['time_cadence']!r}")
10202 lines.append(f
"{prefix}_field_count {len(window['fields'])}")
10203 for field_index, field_entry
in enumerate(window[
"fields"]):
10204 lines.append(f
"{prefix}_field_{field_index}_name {field_entry['field']}")
10205 lines.append(f
"{prefix}_field_{field_index}_moments {','.join(field_entry['moments'])}")
10206 lines.append(f
"{prefix}_covariance_count {len(window['covariances'])}")
10207 for pair_index, pair
in enumerate(window[
"covariances"]):
10208 lines.append(f
"{prefix}_covariance_{pair_index} {pair[0]},{pair[1]}")
10214 @brief Resolve the statistics console cadence, mirroring the particle one.
10215 @param[in] io_cfg Parsed monitor `io` block.
10216 @return Value returned by `resolve_statistics_console_output_frequency()`.
10218 if 'statistics_console_output_frequency' in io_cfg:
10219 return io_cfg[
'statistics_console_output_frequency']
10220 return io_cfg.get(
'data_output_frequency')
10225 @brief Validate and canonicalize physical-solution convergence monitoring.
10226 @param[in] monitor_cfg Parsed monitor.yml mapping.
10227 @return Canonical solution-monitoring configuration.
10228 @throws ValueError when convergence settings are invalid.
10230 if not isinstance(monitor_cfg, dict):
10231 raise ValueError(
"monitor.yml must be a mapping before solution monitoring can be normalized.")
10232 monitoring = monitor_cfg.get(
"solution_monitoring", {})
or {}
10233 if not isinstance(monitoring, dict):
10234 raise ValueError(
"solution_monitoring must be a mapping when provided.")
10235 convergence = monitoring.get(
"convergence", {})
or {}
10236 if not isinstance(convergence, dict):
10237 raise ValueError(
"solution_monitoring.convergence must be a mapping when provided.")
10238 enabled = convergence.get(
"enabled",
True)
10239 if not isinstance(enabled, bool):
10240 raise ValueError(
"solution_monitoring.convergence.enabled must be boolean.")
10242 normalized = {
"enabled": enabled,
"mode": mode.lower()}
10243 periodic_cfg = convergence.get(
"periodic_deterministic")
10244 statistical_cfg = convergence.get(
"statistical_steady")
10245 if mode ==
"PERIODIC_DETERMINISTIC":
10246 if not isinstance(periodic_cfg, dict):
10248 "solution_monitoring.convergence.periodic_deterministic is required for periodic_deterministic mode."
10250 period_steps = periodic_cfg.get(
"period_steps")
10251 if isinstance(period_steps, bool)
or not isinstance(period_steps, int)
or period_steps <= 0:
10253 "solution_monitoring.convergence.periodic_deterministic.period_steps must be a positive integer."
10255 normalized[
"periodic_deterministic"] = {
"period_steps": period_steps}
10256 elif periodic_cfg
is not None:
10258 "solution_monitoring.convergence.periodic_deterministic is only valid for periodic_deterministic mode."
10260 if mode ==
"STATISTICAL_STEADY":
10261 if not isinstance(statistical_cfg, dict):
10263 "solution_monitoring.convergence.statistical_steady is required for statistical_steady mode."
10265 window_steps = statistical_cfg.get(
"window_steps")
10266 if isinstance(window_steps, bool)
or not isinstance(window_steps, int)
or window_steps <= 0:
10268 "solution_monitoring.convergence.statistical_steady.window_steps must be a positive integer."
10270 normalized[
"statistical_steady"] = {
"window_steps": window_steps}
10271 elif statistical_cfg
is not None:
10273 "solution_monitoring.convergence.statistical_steady is only valid for statistical_steady mode."
10275 return {
"convergence": normalized}
10280 @brief Translate solution-monitoring YAML into the existing C convergence flags.
10281 @param[in] monitor_cfg Parsed monitor.yml mapping.
10282 @return Mapping of convergence options to explicit runtime values.
10286 "-solution_convergence_enabled":
"true" if convergence[
"enabled"]
else "false",
10287 "-solution_convergence_mode": f
'"{normalize_solution_convergence_mode(convergence["mode"])}"',
10289 if "periodic_deterministic" in convergence:
10290 flags[
"-solution_convergence_period_steps"] = convergence[
"periodic_deterministic"][
"period_steps"]
10291 if "statistical_steady" in convergence:
10292 flags[
"-solution_convergence_window_steps"] = convergence[
"statistical_steady"][
"window_steps"]
10297 config_dir: str =
None) -> dict:
10299 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
10300 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
10301 @param[in] run_id Argument passed to `prepare_monitor_files()`.
10302 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
10303 @param[in] source_files Argument passed to `prepare_monitor_files()`.
10304 @param[in] config_dir Optional configuration revision directory.
10305 @return Value returned by `prepare_monitor_files()`.
10307 print(
"[INFO] Generating monitoring files...")
10309 whitelist_path =
None
10312 run_dir, run_id, monitor_cfg,
"logging",
"enabled_functions",
"whitelist.run", source_files,
10313 config_dir=config_dir,
10316 print(
"[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
10320 profile_path =
None
10321 if profiling_cfg[
"mode"] ==
"selected":
10325 {
"profiling": {
"selected_functions": profiling_cfg[
"functions"]}},
10327 "selected_functions",
10330 config_dir=config_dir,
10333 print(f
"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
10336 "whitelist": whitelist_path,
10337 "profile": profile_path,
10338 "profiling": profiling_cfg,
10342 config_dir: str =
None) -> list:
10344 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
10345 and returns a list of their absolute paths.
10346 @details Handles both simple list format (for single-block cases) and a
10347 list-of-lists (for multi-block cases) for boundary conditions.
10348 @param[in] run_dir The path to the main run directory.
10349 @param[in] run_id The unique identifier for the run.
10350 @param[in] case_cfg The parsed case.yml configuration dictionary.
10351 @param[in] source_files A dictionary of source files for the header.
10352 @param[in] config_dir Optional configuration revision directory.
10353 @return A list of absolute paths to the generated BC files.
10354 @throws ValueError if the number of BC definitions does not match the number of blocks.
10356 print(
"[INFO] Generating boundary condition files...")
10357 config_dir = config_dir
or os.path.join(run_dir,
"config")
10358 os.makedirs(config_dir, exist_ok=
True)
10359 profile_dir = os.path.join(run_dir,
"inputs",
"inlet_profiles")
10360 os.makedirs(profile_dir, exist_ok=
True)
10361 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
10363 case_path = source_files.get(
"Case")
if source_files
else None
10364 profile_grid_dims =
None
10365 scales = case_cfg.get(
'properties', {}).get(
'scaling', {})
10366 U_ref =
_to_float(scales.get(
'velocity_ref'),
"properties.scaling.velocity_ref")
10368 raise ValueError(
"properties.scaling.velocity_ref must be non-zero for prescribed_flow profile staging.")
10370 if any(bc.get(
"handler") ==
"prescribed_flow" for block
in prepared_blocks
for bc
in block):
10373 generated_files = []
10374 generated_profile_summaries = []
10375 generated_target_grid =
None
10376 field_slice_target_grid =
None
10377 for i, block_bcs_list
in enumerate(prepared_blocks):
10378 file_name =
"bcs.run" if num_blocks == 1
else f
"bcs_block{i}.run"
10379 bcs_file_path = os.path.join(config_dir, file_name)
10382 for bc
in block_bcs_list:
10383 face, bc_type, handler = bc[
'face'], bc[
'type'], bc[
'handler']
10384 params = dict(bc.get(
'params')
or {})
10385 if handler ==
"prescribed_flow":
10386 source = params.pop(
"source")
10388 staged_name = f
"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
10389 staged_path = os.path.join(profile_dir, staged_name)
10390 if os.path.isfile(staged_path):
10391 with open(staged_path,
"r", encoding=
"utf-8")
as stream:
10392 header = [line.strip()
for line
in stream
if line.strip()
and not line.lstrip().startswith(
"#")]
10393 if len(header) < 3
or header[0] !=
"PICSLICE":
10394 raise ValueError(f
"Locked inlet profile is not a PICSLICE file: {staged_path}")
10396 actual_dims = tuple(int(token)
for token
in header[2].split())
10397 except ValueError
as exc:
10398 raise ValueError(f
"Locked inlet profile has invalid dimensions: {staged_path}")
from exc
10399 if actual_dims != tuple(expected_dims):
10401 f
"Locked inlet profile {staged_path} has dimensions {actual_dims}; "
10402 f
"expected {tuple(expected_dims)}."
10405 f
"[INFO] Reusing locked prescribed_flow profile for block {i}, "
10406 f
"face {face}: {os.path.relpath(staged_path)}"
10408 params[
"source_file"] = os.path.abspath(staged_path)
10410 elif source[
"type"] ==
"file":
10412 source[
"path"], os.path.dirname(os.path.abspath(case_path))
10414 elif source[
"type"] ==
"generated":
10415 source_path = os.path.join(
10417 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.dimensional.picslice",
10419 if os.path.abspath(source_path) == os.path.abspath(staged_path):
10421 f
"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
10423 if source[
"generator"] ==
"square_duct_poiseuille":
10424 if generated_target_grid
is None:
10430 target_grid=generated_target_grid,
10433 script=source.get(
"script"),
10434 case_path=case_path,
10437 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
10438 summary.update({
"block": i,
"face": face})
10439 generated_profile_summaries.append(summary)
10440 elif source[
"type"] ==
"field_slice":
10441 source_path = os.path.join(
10443 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.dimensional.picslice",
10445 if os.path.abspath(source_path) == os.path.abspath(staged_path):
10447 f
"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
10449 if field_slice_target_grid
is None:
10455 field_slice_target_grid,
10460 summary.update({
"block": i,
"face": face})
10461 generated_profile_summaries.append(summary)
10462 elif source
is not None:
10463 raise ValueError(f
"Unsupported prescribed_flow source type '{source.get('type')}'.")
10464 if source
is not None:
10467 f
"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
10468 f
"{os.path.relpath(staged_path)} dims={summary['dims']}"
10470 params[
"source_file"] = os.path.abspath(staged_path)
10474 for k, v
in params.items():
10475 if isinstance(v, bool):
10476 value_str =
"true" if v
else "false"
10479 parts.append(f
"{k}={value_str}")
10480 params_str =
" ".join(parts)
10481 bcs_lines.append(f
"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
10483 with open(bcs_file_path,
"w")
as f: f.write(
"\n".join(bcs_lines))
10485 print(f
"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
10486 generated_files.append(os.path.abspath(bcs_file_path))
10488 if generated_profile_summaries:
10490 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
10492 return generated_files
10496 @brief Converts Python types to C-style command-line flag values.
10497 @param[in] value The Python object to convert (bool, list, or other).
10498 @return A string representation suitable for a C command-line parser.
10500 if isinstance(value, bool):
10501 return "1" if value
else "0"
10502 if isinstance(value, list):
10503 return ",".join(map(str, value))
10508 @brief Return programmatic-grid settings translated to the C node-count contract.
10509 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
10510 @return Value returned by `translate_programmatic_grid_settings()`.
10512 translated = dict(grid_settings)
10513 for dim_key
in (
"im",
"jm",
"km"):
10514 if dim_key
in translated:
10515 raw_val = translated[dim_key]
10516 if not isinstance(raw_val, int)
or raw_val <= 0:
10518 f
"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
10519 f
"(got {raw_val!r})."
10521 translated[dim_key] = raw_val + 1
10525PROGRAMMATIC_GENERATED_IC_GRID_KEYS = (
10527 "xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
10528 "rxs",
"rys",
"rzs",
10534 @brief Validate scalar programmatic grid settings needed by file-generating IC providers.
10535 @param[in] raw_settings programmatic_settings dict from case.yml.
10536 @throws ValueError when required scalar settings are missing or invalid.
10538 if not isinstance(raw_settings, dict):
10540 "grid.programmatic_settings must be a mapping for a generated initial condition."
10543 missing = [key
for key
in PROGRAMMATIC_GENERATED_IC_GRID_KEYS
if key
not in raw_settings]
10546 "grid.programmatic_settings must include "
10547 f
"{missing} when grid.mode is 'programmatic_c' and the initial condition requires a grid file."
10550 for key
in (
"im",
"jm",
"km"):
10551 value = raw_settings[key]
10552 if isinstance(value, bool)
or not isinstance(value, int)
or value <= 0:
10554 f
"grid.programmatic_settings.{key} must be a positive scalar integer cell count "
10555 "for programmatic_c with a generated initial condition."
10558 for key
in (
"xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
"rxs",
"rys",
"rzs"):
10559 value = raw_settings[key]
10560 if isinstance(value, (list, tuple, dict, bool)):
10562 f
"grid.programmatic_settings.{key} must be a scalar numeric value "
10563 "for a generated initial condition."
10566 numeric = float(value)
10567 except (TypeError, ValueError):
10569 f
"grid.programmatic_settings.{key} must be a scalar numeric value "
10570 "for a generated initial condition."
10572 if not math.isfinite(numeric):
10574 f
"grid.programmatic_settings.{key} must be finite "
10575 "for a generated initial condition."
10577 if key
in {
"rxs",
"rys",
"rzs"}
and numeric <= 0.0:
10579 f
"grid.programmatic_settings.{key} must be positive "
10580 "for a generated initial condition."
10586 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
10587 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
10588 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
10589 @param[in] raw_settings programmatic_settings dict from case.yml.
10590 @param[in] dest_path Destination PICGRID file path.
10591 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
10592 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
10596 raise ValueError(
"length_ref must be non-zero for programmatic grid generation.")
10597 IM = int(raw_settings.get(
"im", 0)) + 1
10598 JM = int(raw_settings.get(
"jm", 0)) + 1
10599 KM = int(raw_settings.get(
"km", 0)) + 1
10600 if IM < 2
or JM < 2
or KM < 2:
10602 f
"programmatic_settings im/jm/km must each be >= 1 "
10603 f
"(got im={IM-1}, jm={JM-1}, km={KM-1})."
10605 x_min = float(raw_settings.get(
"xMins", 0.0))
10606 x_max = float(raw_settings.get(
"xMaxs", 1.0))
10607 y_min = float(raw_settings.get(
"yMins", 0.0))
10608 y_max = float(raw_settings.get(
"yMaxs", 1.0))
10609 z_min = float(raw_settings.get(
"zMins", 0.0))
10610 z_max = float(raw_settings.get(
"zMaxs", 1.0))
10611 rx = float(raw_settings.get(
"rxs", 1.0))
10612 ry = float(raw_settings.get(
"rys", 1.0))
10613 rz = float(raw_settings.get(
"rzs", 1.0))
10615 def _stretched(idx, N, length, r):
10617 @brief Mirror of ComputeStretchedCoord from src/grid.c.
10618 @param[in] idx Node index along the axis.
10619 @param[in] N Total node count along the axis.
10620 @param[in] length Physical length of the axis.
10621 @param[in] r Geometric stretching ratio.
10622 @return Coordinate offset from the axis minimum.
10624 frac = idx / (N - 1.0)
10625 if abs(r - 1.0) < 1.0e-9:
10626 return length * frac
10627 return length * (r ** frac - 1.0) / (r - 1.0)
10629 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
10630 os.makedirs(os.path.dirname(dest_path), exist_ok=
True)
10631 with open(dest_path,
"w")
as fout:
10632 fout.write(
"PICGRID\n1\n")
10633 fout.write(f
"{IM} {JM} {KM}\n")
10634 for k
in range(KM):
10635 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
10636 for j
in range(JM):
10637 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
10638 for i
in range(IM):
10639 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
10641 f
"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
10642 f
"{format_picgrid_coordinate(z)}\n"
10644 total_nodes = IM * JM * KM
10645 return {
"nblk": 1,
"dims": [(IM, JM, KM)],
"total_nodes": total_nodes}
10648GRID_DA_PROCESSOR_KEYS = (
"da_processors_x",
"da_processors_y",
"da_processors_z")
10653 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
10654 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
10655 @return Value returned by `resolve_grid_da_processor_layout()`.
10660 for key
in GRID_DA_PROCESSOR_KEYS:
10661 value = grid_cfg.get(key)
10662 if isinstance(value, (list, tuple)):
10664 f
"grid.{key} must be a scalar integer. "
10665 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
10667 if value
is not None:
10668 if not isinstance(value, int)
or value <= 0:
10669 raise ValueError(f
"grid.{key} must be a positive integer when provided (got {value}).")
10670 top_level[key] = value
10672 legacy_settings = grid_cfg.get(
"programmatic_settings")
10673 if isinstance(legacy_settings, dict):
10674 for key
in GRID_DA_PROCESSOR_KEYS:
10675 value = legacy_settings.get(key)
10676 if isinstance(value, (list, tuple)):
10678 f
"grid.programmatic_settings.{key} must be a scalar integer. "
10679 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
10681 if value
is not None:
10682 if not isinstance(value, int)
or value <= 0:
10684 f
"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
10686 legacy[key] = value
10689 for key
in GRID_DA_PROCESSOR_KEYS:
10690 top_value = top_level.get(key)
10691 legacy_value = legacy.get(key)
10692 if top_value
is not None and legacy_value
is not None and top_value != legacy_value:
10694 f
"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
10695 "define the processor layout in only one place."
10697 if top_value
is not None:
10698 resolved[key] = top_value
10699 elif legacy_value
is not None:
10700 resolved[key] = legacy_value
10707 @brief Append optional global DMDA layout flags for any grid mode.
10708 @param[in] control_lines Argument passed to `append_grid_da_processor_layout()`.
10709 @param[in] grid_cfg Argument passed to `append_grid_da_processor_layout()`.
10710 @param[in] num_procs Argument passed to `append_grid_da_processor_layout()`.
10715 print(
"[INFO] Letting PETSc automatically determine processor layout.")
10719 print(
"[INFO] Serial run, ignoring da_processors layout.")
10722 if all(layout.get(key)
is not None for key
in GRID_DA_PROCESSOR_KEYS):
10724 for key
in GRID_DA_PROCESSOR_KEYS:
10725 total_layout *= layout[key]
10726 if total_layout != num_procs:
10727 printable =
" x ".join(str(layout[key])
for key
in GRID_DA_PROCESSOR_KEYS)
10729 "DMDA processor layout mismatch: "
10730 f
"grid.da_processors_x/y/z is {printable} (product {total_layout}), "
10731 f
"but this run requests {num_procs} MPI processes. "
10732 "Set da_processors_x/y/z to values whose product equals the requested "
10733 "MPI process count, or remove all three settings to let PETSc choose "
10734 "the layout automatically."
10736 print(f
"[INFO] Applying user-defined processor layout for {num_procs} processes.")
10738 printable =
" x ".join(str(layout.get(key,
"PETSC_DECIDE"))
for key
in GRID_DA_PROCESSOR_KEYS)
10739 print(f
"[INFO] Applying partial processor layout: {printable}.")
10741 for key
in GRID_DA_PROCESSOR_KEYS:
10742 value = layout.get(key)
10743 if value
is not None:
10744 control_lines.append(f
"-{key} {value}")
10748 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
10749 @param[in] value Canonical momentum solver string from YAML.
10750 @return Canonical value accepted by -mom_solver_type.
10751 @throws ValueError if the input cannot be mapped.
10755 raise ValueError(
"momentum solver type cannot be None")
10757 raw = str(value).strip()
10759 "Explicit RK4":
"EXPLICIT_RK",
10760 "Dual Time Picard Jameson RK":
"DUALTIME_PICARD_JAMESON_RK",
10761 "Dual Time Picard RK4":
"DUALTIME_PICARD_JAMESON_RK",
10762 "Newton Krylov":
"newton_krylov",
10766 f
"Unknown momentum solver '{value}'. Use one of: "
10767 "'Explicit RK4', 'Dual Time Picard Jameson RK', 'Newton Krylov'."
10774 @brief Validate and normalize the structured Newton--Krylov solver block.
10775 @param[in] cfg Structured `momentum_solver.newton_krylov` mapping.
10776 @return Normalized copy containing only supported structured fields.
10778 root =
"momentum_solver.newton_krylov"
10779 if not isinstance(cfg, dict):
10780 raise ValueError(f
"{root} must be a mapping.")
10782 unknown = sorted(set(cfg) - {
10783 "jacobian",
"preconditioner",
"nonlinear_solver",
"linear_solver",
10786 raise ValueError(f
"{root} has unsupported key(s): {unknown}.")
10790 def _mapping(parent: dict, key: str, path: str) -> dict:
10792 @brief Read and validate one optional nested Newton mapping.
10793 @param[in] parent Parent mapping.
10794 @param[in] key Nested key to read.
10795 @param[in] path User-facing YAML path for errors.
10796 @return Nested mapping, or an empty mapping when omitted.
10798 value = parent.get(key, {})
10799 if value
is None or not isinstance(value, dict):
10800 raise ValueError(f
"{path} must be a mapping when provided.")
10803 def _method(value, path: str) -> str:
10805 @brief Normalize one nonempty PETSc solver/type token.
10806 @param[in] value YAML token value.
10807 @param[in] path User-facing YAML path for errors.
10808 @return Lowercase PETSc token.
10810 if not isinstance(value, str)
or not value.strip():
10811 raise ValueError(f
"{path} must be a non-empty string.")
10812 return value.strip().lower()
10814 def _tolerance(value, path: str):
10816 @brief Validate one finite nonnegative tolerance.
10817 @param[in] value YAML tolerance value.
10818 @param[in] path User-facing YAML path for errors.
10819 @return Original validated value.
10821 if isinstance(value, bool):
10822 raise ValueError(f
"{path} must be numeric and nonnegative.")
10824 numeric = float(value)
10825 except (TypeError, ValueError)
as exc:
10826 raise ValueError(f
"{path} must be numeric and nonnegative.")
from exc
10827 if not math.isfinite(numeric)
or numeric < 0.0:
10828 raise ValueError(f
"{path} must be numeric and nonnegative.")
10831 def _positive_integer(value, path: str):
10833 @brief Validate one positive integer count.
10834 @param[in] value YAML count value.
10835 @param[in] path User-facing YAML path for errors.
10836 @return Original validated integer.
10838 if isinstance(value, bool)
or not isinstance(value, int)
or value <= 0:
10839 raise ValueError(f
"{path} must be a positive integer.")
10842 jacobian_path = f
"{root}.jacobian"
10843 if "jacobian" not in cfg:
10844 normalized[
"jacobian"] = {
10845 "type":
"finite_difference",
10846 "finite_difference": {
"mode":
"matrix_free"},
10849 jacobian = _mapping(cfg,
"jacobian", jacobian_path)
10850 unknown = sorted(set(jacobian) - {
"type",
"finite_difference"})
10852 raise ValueError(f
"{jacobian_path} has unsupported key(s): {unknown}.")
10853 if "type" not in jacobian:
10854 raise ValueError(f
"{jacobian_path}.type is required when {jacobian_path} is provided.")
10855 jacobian_type = _method(jacobian[
"type"], f
"{jacobian_path}.type")
10856 if jacobian_type ==
"frozen_momentum_approximation":
10858 f
"{jacobian_path}.type 'frozen_momentum_approximation' is not implemented."
10860 if jacobian_type !=
"finite_difference":
10862 f
"{jacobian_path}.type currently supports only 'finite_difference' "
10863 f
"(got '{jacobian_type}')."
10865 finite_difference_path = f
"{jacobian_path}.finite_difference"
10866 if "finite_difference" not in jacobian:
10868 f
"{finite_difference_path} is required when {jacobian_path}.type is "
10869 "'finite_difference'."
10871 finite_difference = _mapping(
10872 jacobian,
"finite_difference", finite_difference_path
10874 unknown = sorted(set(finite_difference) - {
"mode"})
10876 raise ValueError(f
"{finite_difference_path} has unsupported key(s): {unknown}.")
10877 if "mode" not in finite_difference:
10878 raise ValueError(f
"{finite_difference_path}.mode is required.")
10879 finite_difference_mode = _method(
10880 finite_difference[
"mode"], f
"{finite_difference_path}.mode"
10882 if finite_difference_mode ==
"colored_sparse":
10884 f
"{finite_difference_path}.mode 'colored_sparse' is not implemented."
10886 if finite_difference_mode !=
"matrix_free":
10888 f
"{finite_difference_path}.mode currently supports only 'matrix_free' "
10889 f
"(got '{finite_difference_mode}')."
10891 normalized[
"jacobian"] = {
10892 "type": jacobian_type,
10893 "finite_difference": {
"mode": finite_difference_mode},
10896 preconditioner = _mapping(
10897 cfg,
"preconditioner", f
"{root}.preconditioner"
10898 )
if "preconditioner" in cfg
else {}
10899 preconditioner_path = f
"{root}.preconditioner"
10900 unknown = sorted(set(preconditioner) - {
"model",
"structure"})
10902 raise ValueError(f
"{preconditioner_path} has unsupported key(s): {unknown}.")
10903 if "preconditioner" in cfg
and "model" not in preconditioner:
10905 f
"{preconditioner_path}.model is required when {preconditioner_path} is provided."
10907 model = _method(preconditioner.get(
"model",
"none"), f
"{preconditioner_path}.model")
10908 if model
not in NEWTON_KRYLOV_PRECONDITIONER_MODELS:
10910 f
"{preconditioner_path}.model supports only 'none' or 'frozen_momentum_jacobian'."
10912 structure = _mapping(
10913 preconditioner,
"structure", f
"{preconditioner_path}.structure"
10914 )
if "structure" in preconditioner
else {}
10915 unknown = sorted(set(structure) - {
"type"})
10917 raise ValueError(f
"{preconditioner_path}.structure has unsupported key(s): {unknown}.")
10918 structure_type = _method(
10919 structure.get(
"type",
"none"), f
"{preconditioner_path}.structure.type"
10921 if structure_type
not in NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES:
10923 f
"{preconditioner_path}.structure.type supports only 'none' or 'point_block'."
10925 if model ==
"none" and structure:
10926 raise ValueError(f
"{preconditioner_path}.model 'none' does not accept a matrix structure.")
10927 if model ==
"frozen_momentum_jacobian" and structure_type !=
"point_block":
10929 f
"{preconditioner_path}.model 'frozen_momentum_jacobian' requires "
10930 f
"{preconditioner_path}.structure.type 'point_block'."
10932 normalized[
"preconditioner"] = {
10934 "structure": {
"type": structure_type},
10937 nonlinear = _mapping(cfg,
"nonlinear_solver", f
"{root}.nonlinear_solver")
10938 nonlinear_path = f
"{root}.nonlinear_solver"
10939 unknown = sorted(set(nonlinear) - {
10940 "method",
"absolute_tolerance",
"relative_tolerance",
"step_tolerance",
10941 "max_iterations",
"line_search",
"eisenstat_walker",
10944 raise ValueError(f
"{nonlinear_path} has unsupported key(s): {unknown}.")
10946 if "method" in nonlinear:
10947 nonlinear_out[
"method"] = _method(nonlinear[
"method"], f
"{nonlinear_path}.method")
10948 for key
in (
"absolute_tolerance",
"relative_tolerance",
"step_tolerance"):
10949 if key
in nonlinear:
10950 nonlinear_out[key] = _tolerance(nonlinear[key], f
"{nonlinear_path}.{key}")
10951 if "max_iterations" in nonlinear:
10952 nonlinear_out[
"max_iterations"] = _positive_integer(
10953 nonlinear[
"max_iterations"], f
"{nonlinear_path}.max_iterations"
10955 if "line_search" in nonlinear:
10956 line_search = _mapping(nonlinear,
"line_search", f
"{nonlinear_path}.line_search")
10957 unknown = sorted(set(line_search) - {
"type"})
10959 raise ValueError(f
"{nonlinear_path}.line_search has unsupported key(s): {unknown}.")
10960 nonlinear_out[
"line_search"] = {}
10961 if "type" in line_search:
10962 nonlinear_out[
"line_search"][
"type"] = _method(
10963 line_search[
"type"], f
"{nonlinear_path}.line_search.type"
10965 if "eisenstat_walker" in nonlinear:
10966 ew_path = f
"{nonlinear_path}.eisenstat_walker"
10967 ew = _mapping(nonlinear,
"eisenstat_walker", ew_path)
10969 "enabled",
"version",
"initial_relative_tolerance",
10970 "maximum_relative_tolerance",
"gamma",
"exponent",
10971 "safeguard_exponent",
"safeguard_threshold",
10973 unknown = sorted(set(ew) - ew_keys)
10975 raise ValueError(f
"{ew_path} has unsupported key(s): {unknown}.")
10976 enabled = ew.get(
"enabled",
True)
10977 if not isinstance(enabled, bool):
10978 raise ValueError(f
"{ew_path}.enabled must be boolean.")
10979 if not enabled
and set(ew) - {
"enabled"}:
10980 raise ValueError(f
"{ew_path} parameters require enabled: true.")
10981 ew_out = {
"enabled": enabled}
10982 if "version" in ew:
10983 version = ew[
"version"]
10984 if isinstance(version, bool)
or not isinstance(version, int)
or version
not in {1, 2, 3, 4}:
10985 raise ValueError(f
"{ew_path}.version must be one of 1, 2, 3, or 4.")
10986 ew_out[
"version"] = version
10988 "initial_relative_tolerance": (0.0, 1.0,
False,
True),
10989 "maximum_relative_tolerance": (0.0, 1.0,
False,
True),
10990 "gamma": (0.0, 1.0,
False,
False),
10991 "exponent": (1.0, 2.0,
True,
False),
10992 "safeguard_threshold": (0.0, 1.0,
True,
True),
10994 for key, (lower, upper, lower_open, upper_open)
in bounds.items():
10997 value = _tolerance(ew[key], f
"{ew_path}.{key}")
10998 numeric = float(value)
10999 valid_lower = numeric > lower
if lower_open
else numeric >= lower
11000 valid_upper = numeric < upper
if upper_open
else numeric <= upper
11001 if not (valid_lower
and valid_upper):
11002 brackets = (
"(" if lower_open
else "[") + f
"{lower}, {upper}" + (
")" if upper_open
else "]")
11003 raise ValueError(f
"{ew_path}.{key} must be in {brackets}.")
11004 ew_out[key] = value
11005 if "safeguard_exponent" in ew:
11006 ew_out[
"safeguard_exponent"] = _tolerance(
11007 ew[
"safeguard_exponent"], f
"{ew_path}.safeguard_exponent"
11009 nonlinear_out[
"eisenstat_walker"] = ew_out
11010 normalized[
"nonlinear_solver"] = nonlinear_out
11012 linear = _mapping(cfg,
"linear_solver", f
"{root}.linear_solver")
11013 linear_path = f
"{root}.linear_solver"
11014 unknown = sorted(set(linear) - {
11015 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
11016 "gmres",
"preconditioner",
11019 raise ValueError(f
"{linear_path} has unsupported key(s): {unknown}.")
11022 if "method" in linear:
11023 method = _method(linear[
"method"], f
"{linear_path}.method")
11024 linear_out[
"method"] = method
11025 for key
in (
"absolute_tolerance",
"relative_tolerance"):
11027 linear_out[key] = _tolerance(linear[key], f
"{linear_path}.{key}")
11028 if "max_iterations" in linear:
11029 linear_out[
"max_iterations"] = _positive_integer(
11030 linear[
"max_iterations"], f
"{linear_path}.max_iterations"
11032 if "gmres" in linear:
11033 gmres = _mapping(linear,
"gmres", f
"{linear_path}.gmres")
11034 unknown = sorted(set(gmres) - {
"restart"})
11036 raise ValueError(f
"{linear_path}.gmres has unsupported key(s): {unknown}.")
11037 linear_out[
"gmres"] = {}
11038 if "restart" in gmres:
11039 if method
not in GMRES_RESTART_METHODS:
11041 f
"{linear_path}.gmres.restart is valid only when {linear_path}.method "
11042 "is one of 'gmres', 'fgmres', or 'lgmres'."
11044 linear_out[
"gmres"][
"restart"] = _positive_integer(
11045 gmres[
"restart"], f
"{linear_path}.gmres.restart"
11047 if "preconditioner" in linear:
11048 compatibility_pc = _mapping(linear,
"preconditioner", f
"{linear_path}.preconditioner")
11049 unknown = sorted(set(compatibility_pc) - {
"type"})
11051 raise ValueError(f
"{linear_path}.preconditioner has unsupported key(s): {unknown}.")
11052 if "type" in compatibility_pc:
11053 pc_type = _method(compatibility_pc[
"type"], f
"{linear_path}.preconditioner.type")
11054 if pc_type !=
"none":
11056 f
"{linear_path}.preconditioner.type is a deprecated compatibility alias "
11057 "and supports only 'none'."
11059 if "preconditioner" in cfg
and model !=
"none":
11061 f
"{linear_path}.preconditioner.type 'none' conflicts with "
11062 f
"{preconditioner_path}.model '{model}'."
11065 f
"{linear_path}.preconditioner.type is deprecated; use "
11066 f
"{preconditioner_path}.model: none.",
11070 normalized[
"linear_solver"] = linear_out
11075 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
11076 @param[in] value Human-readable solution-convergence mode selector.
11077 @return Canonical string accepted by `-solution_convergence_mode`.
11078 @throws ValueError if the input cannot be mapped.
11081 raise ValueError(
"solution_convergence.mode cannot be None")
11083 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11085 "steady_deterministic":
"STEADY_DETERMINISTIC",
11086 "periodic_deterministic":
"PERIODIC_DETERMINISTIC",
11087 "statistical_steady":
"STATISTICAL_STEADY",
11088 "transient":
"TRANSIENT",
11090 mapped = aliases.get(normalized)
11093 f
"Unknown solution_convergence.mode '{value}'. Use one of: "
11094 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
11100 @brief Maps canonical field init mode names to C enum/int codes (-finit).
11101 @param[in] value Canonical field initialization mode.
11102 @return Canonical integer code accepted by -finit.
11103 @throws ValueError if the input cannot be mapped.
11107 raise ValueError(
"field initialization mode cannot be None")
11113 }.get(str(value).strip())
11116 f
"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
11122 @brief Normalize a file IC field selector to its staged basename and C enum value.
11123 @param[in] value User-facing Ucat or Ucont selector.
11124 @return Tuple of staged field basename and C enum value.
11126 normalized = str(value
or "").strip().lower()
11127 if normalized ==
"ucat":
11129 if normalized ==
"ucont":
11131 raise ValueError(
"initial_conditions.field must be 'Ucat' or 'Ucont'.")
11133GENERATED_IC_PROVIDERS = {
11135 "requires_grid":
True,
11136 "diagnostic_artifacts": (),
11138 "spectral_random_velocity": {
11139 "requires_grid":
True,
11140 "requires_periodic_geometric":
True,
11141 "requires_fresh_3d":
True,
11142 "diagnostic_artifacts": (
11143 (
"summary_json", os.path.join(CANONICAL_RUN_PATHS[
"metrics"],
"initial_condition_summary.json")),
11144 (
"spectrum_csv", INITIAL_CONDITION_SPECTRUM_RELPATH),
11152 @brief Return whether a resolved IC is backed by a registered file generator.
11153 @param[in] resolved_ic Resolved initial-condition contract.
11154 @return True for a registered generated-file provider.
11156 return resolved_ic.get(
"kind")
in GENERATED_IC_PROVIDERS
11161 @brief Resolve the shared physical and nondimensional fluid scaling contract.
11162 @param[in] case_cfg Parsed case configuration.
11163 @return Resolved scaling, viscosity, and Reynolds-number quantities.
11165 properties = case_cfg[
"properties"]
11166 scaling = properties[
"scaling"]
11167 fluid = properties[
"fluid"]
11168 length_ref =
_to_finite_float(scaling[
"length_ref"],
"properties.scaling.length_ref")
11169 velocity_ref =
_to_finite_float(scaling[
"velocity_ref"],
"properties.scaling.velocity_ref")
11171 dynamic_viscosity =
_to_finite_float(fluid[
"viscosity"],
"properties.fluid.viscosity")
11172 if length_ref <= 0.0
or velocity_ref <= 0.0
or density <= 0.0
or dynamic_viscosity < 0.0:
11173 raise ValueError(
"length_ref, velocity_ref, and density must be positive; viscosity must be non-negative.")
11174 reynolds = density*velocity_ref*length_ref/dynamic_viscosity
if dynamic_viscosity
else float(
"inf")
11176 "length_ref": length_ref,
"velocity_ref": velocity_ref,
"density": density,
11177 "dynamic_viscosity": dynamic_viscosity,
"reynolds": reynolds,
11178 "physical_kinematic_viscosity": dynamic_viscosity/density,
11179 "nondimensional_kinematic_viscosity": dynamic_viscosity/(density*velocity_ref*length_ref),
11185 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
11186 @param[in] ic Initial-condition YAML mapping.
11187 @param[in] prepared_blocks Normalized boundary-condition blocks.
11188 @param[in] U_ref Physical reference velocity.
11189 @param[in] provider_context Optional conductor-derived provider context.
11190 @return Normalized launcher initial-condition contract.
11192 if not isinstance(ic, dict):
11193 raise ValueError(
"properties.initial_conditions must be a mapping.")
11194 mode = str(ic.get(
"mode",
"")).strip()
11197 if mode
in LEGACY_FIELD_INIT_SPELLINGS:
11200 if finit_code == 1
and params.pop(
"ic_coordinate_system", 0) == 1:
11202 return {
"finit": finit_code,
"cli_params": params,
"kind":
"builtin",
"label": mode}
11204 normalized_mode = mode.lower().replace(
"-",
"_").replace(
" ",
"_")
11205 if normalized_mode ==
"file":
11206 if prepared_blocks
and len(prepared_blocks) > 1:
11207 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
11208 source_file = ic.get(
"source_file")
11209 if not isinstance(source_file, str)
or not source_file.strip():
11210 raise ValueError(
"initial_conditions.source_file is required when mode is 'file'.")
11213 "finit": 4,
"cli_params": {},
"kind":
"file",
"label":
"file",
11214 "source_file": source_file.strip(),
"field_name": field_name,
"field_code": field_code,
11216 if normalized_mode !=
"generated":
11217 raise ValueError(
"initial_conditions.mode must be 'generated' or 'file'.")
11219 generator = str(ic.get(
"generator",
"")).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11220 params = ic.get(
"params", {})
11221 if not isinstance(params, dict):
11222 raise ValueError(
"initial_conditions.params must be a mapping.")
11223 if generator ==
"ic_gen":
11224 if prepared_blocks
and len(prepared_blocks) > 1:
11225 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
11226 script = params.get(
"script")
11227 if script
is not None and (
not isinstance(script, str)
or not script.strip()):
11228 raise ValueError(
"initial_conditions.params.script must be a non-empty path when provided.")
11230 config_file = params.get(
"config_file")
11231 if not isinstance(config_file, str)
or not config_file.strip():
11232 raise ValueError(
"initial_conditions.params.config_file is required for generator 'ic_gen'.")
11233 cli_args = params.get(
"cli_args", [])
11234 if cli_args
is None:
11236 if not isinstance(cli_args, list):
11237 raise ValueError(
"initial_conditions.params.cli_args must be a list.")
11239 "finit": 4,
"cli_params": {},
"kind":
"ic_gen",
"label":
"ic_gen",
11240 "field_name": field_name,
"field_code": field_code,
11241 "config_file": config_file.strip(),
11242 "script": script.strip()
if script
is not None else None,
11243 "output_file": params.get(
"output_file"),
11244 "cli_args": cli_args,
11247 if generator ==
"spectral_random_velocity":
11248 if prepared_blocks
and len(prepared_blocks) != 1:
11249 raise ValueError(
"spectral_random_velocity requires exactly one grid block.")
11250 if not prepared_blocks
or len(prepared_blocks[0]) != 6
or any(
11251 bc.get(
"type") !=
"PERIODIC" or bc.get(
"handler") !=
"geometric"
11252 for bc
in prepared_blocks[0]
11254 raise ValueError(
"spectral_random_velocity requires PERIODIC/geometric boundaries on all six faces.")
11255 allowed = {
"field",
"seed",
"random",
"spectrum",
"projection",
"normalization",
"remove_mean",
11256 "output_file",
"summary_json",
"spectrum_csv"}
11257 unknown = sorted(set(params) - allowed)
11259 raise ValueError(f
"spectral_random_velocity has unsupported params: {unknown}.")
11260 for path_key
in (
"output_file",
"summary_json",
"spectrum_csv"):
11261 value = params.get(path_key)
11262 if value
is not None and (
not isinstance(value, str)
or not value.strip()):
11263 raise ValueError(f
"spectral_random_velocity params.{path_key} must be a non-empty path when provided.")
11265 if field_code != 0:
11266 raise ValueError(
"spectral_random_velocity supports only params.field: Ucat.")
11267 seed = params.get(
"seed", 12345)
11268 if isinstance(seed, bool)
or not isinstance(seed, int):
11269 raise ValueError(
"spectral_random_velocity params.seed must be an integer.")
11270 random_cfg = params.get(
"random", {})
11271 if not isinstance(random_cfg, dict)
or set(random_cfg) - {
"distribution",
"mean"}:
11272 raise ValueError(
"spectral_random_velocity params.random supports only distribution and mean.")
11273 distribution = str(random_cfg.get(
"distribution",
"gaussian")).lower()
11274 if distribution !=
"gaussian":
11275 raise ValueError(
"spectral_random_velocity supports only random.distribution: gaussian.")
11276 mean = random_cfg.get(
"mean", [0.0, 0.0, 0.0])
11277 if not isinstance(mean, list)
or len(mean) != 3:
11278 raise ValueError(
"spectral_random_velocity random.mean must be a three-component list.")
11279 mean = [
_to_finite_float(value, f
"initial_conditions.params.random.mean[{index}]")
11280 for index, value
in enumerate(mean)]
11281 spectrum = params.get(
"spectrum", {})
11282 if not isinstance(spectrum, dict):
11283 raise ValueError(
"spectral_random_velocity params.spectrum must be a mapping.")
11284 spectrum_type = str(spectrum.get(
"type",
"white")).lower()
11285 if spectrum_type ==
"white":
11286 if set(spectrum) - {
"type"}:
11287 raise ValueError(
"white spectrum supports only type.")
11288 normalized_spectrum = {
"type":
"white"}
11289 elif spectrum_type ==
"k4_exponential":
11290 if set(spectrum) - {
"type",
"k0",
"k_cut"}
or "k0" not in spectrum
or "k_cut" not in spectrum:
11291 raise ValueError(
"k4_exponential spectrum requires only type, k0, and k_cut.")
11292 normalized_spectrum = {
"type":
"k4_exponential",
11293 "k0":
_to_finite_float(spectrum[
"k0"],
"initial_conditions.params.spectrum.k0"),
11294 "k_cut":
_to_finite_float(spectrum[
"k_cut"],
"initial_conditions.params.spectrum.k_cut")}
11296 raise ValueError(
"spectrum.type must be 'white' or 'k4_exponential'.")
11297 if any(value <= 0
for key, value
in normalized_spectrum.items()
if key !=
"type"):
11298 raise ValueError(
"spectrum k0 and k_cut values must be positive.")
11299 projection = params.get(
"projection", {
"type":
"none"})
11300 if not isinstance(projection, dict)
or set(projection) - {
"type",
"operator"}:
11301 raise ValueError(
"projection supports only type and operator.")
11302 projection_type = str(projection.get(
"type",
"none")).lower()
11303 if projection_type ==
"none" and "operator" not in projection:
11304 normalized_projection = {
"type":
"none"}
11305 elif projection_type ==
"solenoidal" and str(projection.get(
"operator",
"")).lower()
in PROJECTION_OPERATORS:
11306 normalized_projection = {
"type":
"solenoidal",
"operator": str(projection[
"operator"]).lower()}
11308 raise ValueError(
"projection must be none, or solenoidal with continuum/picurv_discrete operator.")
11309 normalization = params.get(
"normalization", {
"type":
"none"})
11310 if not isinstance(normalization, dict)
or set(normalization) - {
"type",
"target"}:
11311 raise ValueError(
"normalization supports only type and target.")
11312 normalization_type = str(normalization.get(
"type",
"none")).lower()
11313 if normalization_type ==
"none" and "target" not in normalization:
11314 normalized_normalization = {
"type":
"none"}
11315 elif normalization_type ==
"component_rms" and "target" in normalization:
11316 target =
_to_finite_float(normalization[
"target"],
"initial_conditions.params.normalization.target")
11318 raise ValueError(
"component_rms normalization.target must be positive.")
11319 normalized_normalization = {
"type":
"component_rms",
"target": target}
11321 raise ValueError(
"normalization must be none or component_rms with target.")
11322 remove_mean = params.get(
"remove_mean",
True)
11323 if not isinstance(remove_mean, bool):
11324 raise ValueError(
"spectral_random_velocity remove_mean must be boolean.")
11325 context = dict(provider_context
or {})
11327 "finit": 4,
"cli_params": {},
"kind":
"spectral_random_velocity",
"label":
"spectral_random_velocity",
11328 "field_name": field_name,
"field_code": field_code,
"provider_context": context,
11329 "params": {
"field":
"Ucat",
"seed": seed,
11330 "random": {
"distribution": distribution,
"mean": mean},
11331 "spectrum": normalized_spectrum,
"projection": normalized_projection,
11332 "normalization": normalized_normalization,
"remove_mean": remove_mean},
11333 "output_file": params.get(
"output_file"),
"summary_json": params.get(
"summary_json"),
11334 "spectrum_csv": params.get(
"spectrum_csv"),
11337 generator_modes = {
11338 "zero": (0,
"Zero"),
11339 "constant": (1,
"Constant"),
11340 "streamwise_constant": (3,
"Constant"),
11341 "poiseuille": (2,
"Poiseuille"),
11343 if generator
not in generator_modes:
11345 "initial_conditions.generator must be one of: zero, constant, "
11346 "streamwise_constant, poiseuille, ic_gen, spectral_random_velocity."
11348 finit_code, legacy_mode = generator_modes[generator]
11349 legacy_ic = dict(params)
11350 legacy_ic[
"mode"] = legacy_mode
11353 1
if finit_code == 3
else finit_code,
11357 cli_params.pop(
"ic_coordinate_system",
None)
11358 return {
"finit": finit_code,
"cli_params": cli_params,
"kind":
"builtin",
"label": generator}
11362 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
11363 @param[in] path PETSc binary vector path.
11364 @return Summary containing the absolute path and scalar count.
11367 with open(path,
"rb")
as fin:
11368 header = fin.read(8)
11369 if len(header) != 8:
11370 raise ValueError(f
"PETSc Vec file is too short: {path}")
11371 class_id, scalar_count = struct.unpack(
">ii", header)
11372 if class_id != 1211214
or scalar_count < 0:
11373 raise ValueError(f
"Invalid PETSc Vec header in {path}.")
11374 payload = fin.read()
11375 if len(payload) != scalar_count * 8:
11377 f
"PETSc Vec payload size mismatch in {path}: expected {scalar_count * 8} bytes, found {len(payload)}."
11379 return {
"path": os.path.abspath(path),
"scalar_count": scalar_count}
11382 spectrum_path: str, case_dir: str) -> str:
11384 @brief Measure the shell-averaged spectrum of a staged initial condition.
11386 The spectrum has a single implementation in `generators/spectra.gen`, so the
11387 conductor measures the generated field rather than asking the initial-condition
11388 generator to report a spectrum it would have to bin itself.
11390 @param[in] field_path Generated PETSc binary Ucat path.
11391 @param[in] staged_grid Staged canonical PICGRID path.
11392 @param[in] spectrum_path Destination `k,energy` CSV path.
11393 @param[in] case_dir Working directory for the subprocess.
11394 @return Absolute path to the written spectrum CSV.
11395 @throws ValueError when the generator is missing or fails.
11397 script = os.path.join(GENERATORS_PATH,
"spectra.gen")
11398 if not os.path.isfile(script):
11399 raise ValueError(f
"spectra.gen script not found: {script}")
11400 cmd = [sys.executable, script,
"shell-spectrum",
11401 "--field-file", field_path,
"--source-grid", staged_grid,
11402 "--spectrum-csv", spectrum_path]
11403 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
11404 if result.returncode != 0:
11405 details = (result.stderr
or result.stdout
or "").strip()
11407 f
"initial-condition spectrum failed with exit code {result.returncode}. Details:\n{details}"
11409 return os.path.abspath(spectrum_path)
11414 @brief Run the repository IC generator.
11415 @param[in] case_path Source case YAML path.
11416 @param[in] run_dir Run or precompute output directory.
11417 @param[in] resolved_ic Normalized external-generator contract.
11418 @return Generated PETSc vector path.
11420 case_dir = os.path.dirname(os.path.abspath(case_path))
11421 if resolved_ic[
"kind"] ==
"spectral_random_velocity":
11422 script = os.path.join(GENERATORS_PATH,
"ic.gen")
11423 output_path = os.path.join(run_dir,
"inputs",
"initial_condition",
"initial_condition.generated.dat")
11424 os.makedirs(os.path.dirname(output_path), exist_ok=
True)
11425 staged_grid = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
11426 if not os.path.isfile(staged_grid):
11427 raise ValueError(
"spectral_random_velocity requires a staged PICGRID at inputs/grid/grid.run.")
11428 summary_path = os.path.join(run_dir,
"output",
"analysis",
"metrics",
"initial_condition_summary.json")
11429 spectrum_path = os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)
11430 os.makedirs(os.path.dirname(summary_path), exist_ok=
True)
11431 os.makedirs(os.path.dirname(spectrum_path), exist_ok=
True)
11432 cmd = [sys.executable, script,
"--generator",
"spectral_random_velocity",
11433 "--grid", staged_grid,
"--output", output_path,
11434 "--params-json", json.dumps(resolved_ic[
"params"], sort_keys=
True),
11435 "--context-json", json.dumps(resolved_ic.get(
"provider_context", {}), sort_keys=
True),
11436 "--summary-json", summary_path]
11437 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
11438 if result.returncode != 0:
11439 details = (result.stderr
or result.stdout
or "").strip()
11440 raise ValueError(f
"spectral_random_velocity failed with exit code {result.returncode}. Details:\n{details}")
11445 config_file = resolved_ic[
"config_file"]
11447 if not os.path.isfile(script):
11448 raise ValueError(f
"ic.gen script not found: {script}")
11449 if not os.path.isfile(config_file):
11450 raise ValueError(f
"initial-condition generator config file not found: {config_file}")
11451 output_path = os.path.join(run_dir,
"inputs",
"initial_condition",
"initial_condition.generated.dat")
11452 os.makedirs(os.path.dirname(output_path), exist_ok=
True)
11453 cmd = [sys.executable, script,
"-c", config_file,
"--field",
11454 "Ucat" if resolved_ic[
"field_code"] == 0
else "Ucont",
11455 "--output", output_path]
11456 staged_grid = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
11457 if os.path.isfile(staged_grid):
11458 cmd.extend([
"--grid", staged_grid])
11459 cmd.extend(str(token)
for token
in resolved_ic.get(
"cli_args", []))
11460 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
11461 if result.returncode != 0:
11462 details = (result.stderr
or result.stdout
or "").strip()
11463 raise ValueError(f
"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
11469 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
11470 @param[in] run_dir Run or precompute output directory.
11471 @param[in] case_path Source case YAML path.
11472 @param[in] resolved_ic Normalized file-backed IC contract.
11473 @return Source, staged path, and staging-directory summary.
11475 stage_dir = os.path.join(run_dir,
"inputs",
"initial_condition")
11476 os.makedirs(stage_dir, exist_ok=
True)
11477 staged_path = os.path.join(stage_dir, f
"{resolved_ic['field_name']}00000_0.dat")
11478 if os.path.isfile(staged_path):
11481 "source": os.path.abspath(staged_path),
11482 "staged": os.path.abspath(staged_path),
11483 "directory": os.path.abspath(stage_dir),
11486 if resolved_ic[
"kind"] ==
"spectral_random_velocity":
11487 summary[
"diagnostics"] = [
11488 os.path.join(run_dir,
"output",
"analysis",
"metrics",
"initial_condition_summary.json"),
11489 os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH),
11496 resolved_ic[
"source_file"], os.path.dirname(os.path.abspath(case_path))
11498 if not os.path.isfile(source_path):
11499 raise ValueError(f
"Initial-condition source file not found: {source_path}")
11501 if os.path.abspath(source_path) != os.path.abspath(staged_path):
11502 shutil.copy2(source_path, staged_path)
11503 summary = {
"source": os.path.abspath(source_path),
"staged": os.path.abspath(staged_path),
11504 "directory": os.path.abspath(stage_dir)}
11505 if resolved_ic[
"kind"] ==
"spectral_random_velocity":
11506 summary[
"diagnostics"] = [
11507 os.path.join(run_dir,
"output",
"analysis",
"metrics",
"initial_condition_summary.json"),
11508 os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH),
11514 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
11515 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
11516 @return Integer 0-5 matching the FlowDirection enum.
11517 @throws ValueError on unknown value.
11520 "+Xi": 0,
"-Xi": 1,
11521 "+Eta": 2,
"-Eta": 3,
11522 "+Zeta": 4,
"-Zeta": 5,
11523 }.get(str(value).strip())
11526 f
"Unknown initial_conditions.flow_direction '{value}'. "
11527 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
11533 @brief Return True if any prepared BC block contains an INLET face.
11534 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
11535 @return True if at least one INLET entry exists across all blocks.
11537 if not prepared_blocks:
11539 for block_bcs
in prepared_blocks:
11540 for entry
in block_bcs:
11541 if entry.get(
"type") ==
"INLET":
11547 @brief Resolve all IC parameters and return a dict of PETSc option values.
11548 @param[in] ic The properties.initial_conditions mapping.
11549 @param[in] finit_code Normalized -finit integer code.
11550 @param[in] prepared_blocks Normalized BC blocks (may be None).
11551 @param[in] U_ref Reference velocity for non-dimensionalization.
11552 @return Dict with keys matching PETSc option names (without leading dash).
11553 @throws KeyError if a required key is absent.
11554 @throws ValueError on invalid combinations or values.
11558 if finit_code == 0:
11561 if finit_code == 1:
11562 has_cartesian = any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical"))
11563 has_curvilinear =
"velocity_physical" in ic
11565 if has_cartesian
and has_curvilinear:
11567 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
11568 "velocity_physical (curvilinear) — use one or the other."
11571 if has_curvilinear:
11573 result[
"ic_coordinate_system"] = cs_code
11575 vel_phys = float(ic[
"velocity_physical"])
11576 except (TypeError, ValueError)
as exc:
11578 f
"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
11579 "Expected a numeric value."
11581 result[
"ic_velocity_physical"] = vel_phys / U_ref
if U_ref != 0
else 0.0
11583 if "flow_direction" in ic:
11587 "initial_conditions.flow_direction is required for curvilinear Constant IC "
11588 "when no INLET face exists."
11592 if "flow_direction" in ic:
11594 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
11595 "Use velocity_physical + flow_direction for curvilinear mode."
11598 result[
"ic_coordinate_system"] = cs_code
11600 scale = 1.0 / U_ref
if U_ref != 0
else 0.0
11601 result[
"ucont_x"] = u * scale
11602 result[
"ucont_y"] = v * scale
11603 result[
"ucont_z"] = w * scale
11605 elif finit_code == 2:
11606 if any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical")):
11608 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
11610 if "velocity_physical" in ic:
11612 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
11614 if "peak_velocity_physical" not in ic:
11615 raise KeyError(
"peak_velocity_physical")
11617 peak = float(ic[
"peak_velocity_physical"])
11618 except (TypeError, ValueError)
as exc:
11620 f
"Invalid value for initial_conditions.peak_velocity_physical: "
11621 f
"{ic['peak_velocity_physical']!r}. Expected a numeric value."
11623 result[
"ic_velocity_physical"] = peak / U_ref
if U_ref != 0
else 0.0
11625 if "flow_direction" in ic:
11630 fd_axis_name = {0:
"x", 1:
"y", 2:
"z"}.get(fd_int // 2,
"?")
11631 if inlet_axis
and fd_axis_name != inlet_axis:
11632 token = ic[
"flow_direction"]
11634 f
"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
11635 f
"does not match INLET face axis '{inlet_axis}'."
11637 result[
"flow_direction"] = fd_int
11640 "initial_conditions.flow_direction is required for Poiseuille IC "
11641 "when no INLET face exists."
11648 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
11649 @param[in] value Human-readable or enum-like Eulerian field source.
11650 @return Canonical string accepted by `-euler_field_source`.
11651 @throws ValueError if the input cannot be mapped.
11654 raise ValueError(
"eulerian_field_source cannot be None")
11656 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11660 "analytical":
"analytical",
11662 mapped = aliases.get(normalized)
11665 f
"Unknown operation_mode.eulerian_field_source '{value}'. "
11666 "Use one of: 'solve', 'load', 'analytical'."
11672 @brief Normalizes the analytical solution selector to the C-side canonical string.
11673 @param[in] value Human-readable analytical solution selector.
11674 @return Canonical string accepted by `-analytical_type`.
11675 @throws ValueError if the input cannot be mapped.
11679 raise ValueError(
"analytical_type cannot be None")
11681 normalized = str(value).strip().upper().replace(
"-",
"_").replace(
" ",
"_")
11682 if normalized
not in ANALYTICAL_SOLUTION_TYPES:
11684 f
"Unknown operation_mode.analytical_type '{value}'. "
11685 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
11691 @brief Parse initial-condition velocity components with mode-aware defaults.
11692 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
11693 @param[in] finit_code Normalized `-finit` integer code.
11694 @param[in] require_explicit If True, all three component keys must be present.
11695 @return Tuple `(u, v, w)` in physical units.
11696 @throws KeyError if a required component key is missing.
11697 @throws ValueError if a component cannot be converted to float.
11699 component_keys = (
"u_physical",
"v_physical",
"w_physical")
11701 for key
in component_keys:
11702 if key
not in initial_conditions:
11703 if require_explicit:
11704 raise KeyError(key)
11707 raw_value = initial_conditions[key]
11709 components.append(float(raw_value))
11710 except (TypeError, ValueError)
as exc:
11712 f
"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
11714 return tuple(components)
11718 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
11719 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
11720 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
11721 @throws ValueError if different blocks imply different inlet axes.
11723 face_order = (
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta")
11725 "-Xi":
"x",
"+Xi":
"x",
11726 "-Eta":
"y",
"+Eta":
"y",
11727 "-Zeta":
"z",
"+Zeta":
"z",
11731 for block_bcs
in prepared_blocks:
11732 face_map = {entry[
"face"]: entry
for entry
in block_bcs}
11733 for face
in face_order:
11734 entry = face_map.get(face)
11735 if entry
and entry[
"type"] ==
"INLET":
11736 inlet_axes.add(face_axis[face])
11741 if len(inlet_axes) != 1:
11743 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
11744 f
"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
11746 return next(iter(inlet_axes))
11750 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
11751 @param[in] value Canonical particle initialization mode.
11752 @return Canonical integer code accepted by -pinit.
11753 @throws ValueError if the input cannot be mapped.
11757 raise ValueError(
"particle init mode cannot be None")
11764 }.get(str(value).strip())
11767 f
"Unknown particle init_mode '{value}'. Use one of: "
11768 "'Surface', 'Volume', 'PointSource', 'SurfaceEdges'."
11774 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
11775 @param[in] value Canonical interpolation method name.
11776 @return Integer code accepted by -interpolation_method.
11777 @throws ValueError if the input cannot be mapped.
11780 raise ValueError(
"interpolation method cannot be None")
11784 "CornerAveraged": 1,
11785 }.get(str(value).strip())
11788 f
"Unknown interpolation_method '{value}'. Use one of: "
11789 "'Trilinear', 'CornerAveraged'."
11795 @brief Maps LES model selectors to C enum/int codes (-les).
11796 @param[in] value LES selector name or legacy integer/bool value.
11797 @return Integer code accepted by -les.
11798 @throws ValueError if the input cannot be mapped.
11800 if isinstance(value, bool):
11801 return 1
if value
else 0
11802 if isinstance(value, int):
11803 if value
in (0, 1, 2):
11805 raise ValueError(
"models.physics.turbulence.les must be 0, 1, 2, false/true, or a supported model block.")
11807 raise ValueError(
"LES model cannot be None")
11809 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11816 "constant_smagorinsky": 1,
11819 "dynamic_smagorinsky": 2,
11823 f
"Unknown LES model '{value}'. Use one of: 'none', "
11824 "'constant_smagorinsky', 'dynamic_smagorinsky'."
11830 @brief Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
11831 @param[in] value Test-filter selector name or integer code.
11832 @return 0 for the volume-weighted box filter, 1 for the i/k Simpson filter.
11833 @throws ValueError if the input cannot be mapped.
11835 if isinstance(value, bool):
11836 raise ValueError(
"models.physics.turbulence.les.test_filter.kernel must name a filter, not a boolean.")
11837 if isinstance(value, int):
11838 if value
in (0, 1):
11840 raise ValueError(
"models.physics.turbulence.les.test_filter.kernel must be 0, 1, or a supported filter name.")
11842 raise ValueError(
"LES test_filter.kernel cannot be None")
11844 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11846 "volume_weighted_box": 0,
11852 f
"Unknown LES test filter kernel '{value}'. Use one of: "
11853 "'volume_weighted_box', 'simpson_ik'."
11859 @brief Maps LES grid-filter-width model names to the C -les_filter_width flag.
11860 @param[in] value Filter-width model name or integer code.
11861 @return 0 for cube-root volume, 1 for the geometric mean of the cell extents,
11862 2 for the longest cell extent.
11863 @throws ValueError if the input cannot be mapped.
11865 if isinstance(value, bool):
11866 raise ValueError(
"models.physics.turbulence.les.filter_width must name a model, not a boolean.")
11867 if isinstance(value, int):
11868 if value
in (0, 1, 2):
11870 raise ValueError(
"models.physics.turbulence.les.filter_width must be 0, 1, 2, or a supported model name.")
11872 raise ValueError(
"LES filter_width cannot be None")
11874 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11876 "cube_root_volume": 0,
11877 "geometric_mean": 1,
11882 f
"Unknown LES filter width model '{value}'. Use one of: "
11883 "'cube_root_volume', 'geometric_mean', 'max_edge'."
11889 @brief Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
11890 @param[in] value Averaging mode name or integer code.
11891 @return 0 for pointwise local averaging, 1 for homogeneous directions, 2 for the
11893 @throws ValueError if the input cannot be mapped.
11895 if isinstance(value, bool):
11896 raise ValueError(
"models.physics.turbulence.les.averaging.mode must name a mode, not a boolean.")
11897 if isinstance(value, int):
11898 if value
in (0, 1, 2):
11900 raise ValueError(
"models.physics.turbulence.les.averaging.mode must be 0, 1, 2, or a supported mode name.")
11902 raise ValueError(
"LES averaging.mode cannot be None")
11904 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11912 f
"Unknown LES averaging mode '{value}'. Use one of: "
11913 "'local', 'homogeneous', 'global'."
11919 @brief Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
11920 @param[in] value Clipping mode name or integer code.
11921 @return 0 to clamp into [0, max_cs^2], 1 to discard negatives only, 2 to keep the
11922 signed coefficient so backscatter survives.
11923 @throws ValueError if the input cannot be mapped.
11925 if isinstance(value, bool):
11926 raise ValueError(
"models.physics.turbulence.les.clipping.mode must name a mode, not a boolean.")
11927 if isinstance(value, int):
11928 if value
in (0, 1, 2):
11930 raise ValueError(
"models.physics.turbulence.les.clipping.mode must be 0, 1, 2, or a supported mode name.")
11932 raise ValueError(
"LES clipping.mode cannot be None")
11934 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
11937 "clip_negative": 1,
11942 f
"Unknown LES clipping mode '{value}'. Use one of: "
11943 "'clamp', 'clip_negative', 'none'."
11948LES_AVERAGING_DIRECTION_AXES = (
"i",
"j",
"k")
11952 @brief Maps a list of homogeneous logical directions to the C flag's string form.
11953 @param[in] value List or string naming a subset of the i, j, and k directions.
11954 @return The selected directions as a canonically ordered subset of "ijk".
11955 @throws ValueError if a direction is unknown or repeated.
11959 if isinstance(value, str):
11960 tokens = [token
for token
in value.strip().lower()]
11961 elif isinstance(value, (list, tuple)):
11962 tokens = [str(token).strip().lower()
for token
in value]
11965 "models.physics.turbulence.les.averaging.directions must be a list such as [i, k]."
11969 for token
in tokens:
11970 if token
not in LES_AVERAGING_DIRECTION_AXES:
11972 f
"Unknown LES averaging direction '{token}'. Use a subset of ['i', 'j', 'k']."
11974 if token
in selected:
11976 f
"LES averaging direction '{token}' is repeated; list each direction once."
11978 selected.append(token)
11979 return "".join(axis
for axis
in LES_AVERAGING_DIRECTION_AXES
if axis
in selected)
11983 @brief Maps RANS model selectors to the current C -rans switch.
11984 @param[in] value RANS selector name or legacy integer/bool value.
11985 @return Integer code accepted by -rans.
11986 @throws ValueError if the input cannot be mapped.
11988 if isinstance(value, bool):
11989 return 1
if value
else 0
11990 if isinstance(value, int):
11991 if value
in (0, 1):
11993 raise ValueError(
"models.physics.turbulence.rans must be 0, 1, false/true, or a supported model block.")
11995 raise ValueError(
"RANS model cannot be None")
11997 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
12006 raise ValueError(f
"Unknown RANS model '{value}'. Use one of: 'none', 'k_omega'.")
12011 @brief Maps wall-function model selectors to the C -wallfunction flag.
12012 @param[in] value Wall-function selector name, or None for the default.
12013 @return Integer code accepted by -wallfunction; 1 log law, 2 Werner-Wengle, 3 Cabot.
12014 @throws ValueError if the input cannot be mapped.
12018 if isinstance(value, bool):
12019 raise ValueError(
"models.physics.turbulence.wall_function.model must name a model, not a boolean.")
12020 if isinstance(value, int):
12021 if value
in (1, 2, 3):
12023 raise ValueError(
"models.physics.turbulence.wall_function.model must be 1, 2, 3, or a supported model name.")
12024 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
12029 "werner_wengle": 2,
12034 "Unknown wall_function model '%s'. Use one of: 'log_law', 'werner', 'cabot'." % value)
12039 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
12040 @param[in] cfg Mapping that may contain `enabled`.
12041 @param[in] path Human-readable config path for diagnostics.
12042 @param[in] default Value used when `enabled` is omitted.
12043 @return Boolean enabled state.
12044 @throws ValueError if `enabled` is not a YAML boolean.
12046 if 'enabled' not in cfg:
12048 if not isinstance(cfg[
'enabled'], bool):
12049 raise ValueError(f
"{path}.enabled must be true or false.")
12050 return cfg[
'enabled']
12054 @brief Appends the LES closure parameter flags from a structured les block.
12055 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
12056 @param[out] control_lines A list of strings to which C-flags will be appended.
12057 @throws ValueError if a selector name or nested block shape is unsupported.
12059 if 'constant_cs' in les_cfg:
12060 control_lines.append(f
"-les_constant_cs {format_flag_value(les_cfg['constant_cs'])}")
12061 if 'dynamic_frequency' in les_cfg:
12062 control_lines.append(f
"-les_dynamic_frequency {format_flag_value(les_cfg['dynamic_frequency'])}")
12063 if 'filter_width' in les_cfg:
12064 control_lines.append(f
"-les_filter_width {normalize_les_filter_width(les_cfg['filter_width'])}")
12068 test_filter = les_cfg.get(
'test_filter')
12069 if test_filter
is not None:
12070 if not isinstance(test_filter, dict):
12071 test_filter = {
'kernel': test_filter}
12072 if 'kernel' in test_filter:
12073 control_lines.append(
12074 f
"-les_test_filter_kernel {normalize_les_test_filter(test_filter['kernel'])}")
12075 if 'width_ratio' in test_filter:
12076 control_lines.append(
12077 f
"-les_test_filter_width_ratio {format_flag_value(test_filter['width_ratio'])}")
12079 averaging = les_cfg.get(
'averaging')
12080 if averaging
is not None:
12081 if not isinstance(averaging, dict):
12082 averaging = {
'mode': averaging}
12083 if 'mode' in averaging:
12084 control_lines.append(
12085 f
"-les_averaging_mode {normalize_les_averaging_mode(averaging['mode'])}")
12086 if 'directions' in averaging:
12089 control_lines.append(f
"-les_averaging_directions {directions}")
12091 clipping = les_cfg.get(
'clipping')
12092 if clipping
is not None:
12093 if not isinstance(clipping, dict):
12094 clipping = {
'mode': clipping}
12095 if 'mode' in clipping:
12096 control_lines.append(f
"-les_clip_mode {normalize_les_clip_mode(clipping['mode'])}")
12097 if 'max_cs' in clipping:
12098 control_lines.append(f
"-les_clip_max_cs {format_flag_value(clipping['max_cs'])}")
12099 if 'min_viscosity_ratio' in clipping:
12100 control_lines.append(
12101 f
"-les_min_viscosity_ratio {format_flag_value(clipping['min_viscosity_ratio'])}")
12103 gradient_model = les_cfg.get(
'gradient_model')
12104 if gradient_model
is not None:
12105 if not isinstance(gradient_model, dict):
12106 gradient_model = {
'enabled': gradient_model}
12108 "models.physics.turbulence.les.gradient_model")
12109 control_lines.append(f
"-les_gradient_model {1 if enabled else 0}")
12111 diagnostics = les_cfg.get(
'diagnostics')
12112 if diagnostics
is not None:
12113 if not isinstance(diagnostics, dict):
12114 diagnostics = {
'enabled': diagnostics}
12116 control_lines.append(f
"-les_diagnostics {'true' if enabled else 'false'}")
12117 if 'cadence' in diagnostics:
12118 control_lines.append(
12119 f
"-les_diagnostics_cadence {format_flag_value(diagnostics['cadence'])}")
12120 if 'yoshizawa_ci' in diagnostics:
12121 control_lines.append(
12122 f
"-les_yoshizawa_ci {format_flag_value(diagnostics['yoshizawa_ci'])}")
12126 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
12127 @param[in] models Parsed case.yml `models` mapping.
12128 @param[out] control_lines A list of strings to which C-flags will be appended.
12130 turbulence_cfg = models.get(
'physics', {}).get(
'turbulence', {})
12131 if not turbulence_cfg:
12133 if not isinstance(turbulence_cfg, dict):
12134 raise ValueError(
"models.physics.turbulence must be a mapping.")
12136 les_cfg = turbulence_cfg.get(
'les')
12137 rans_cfg = turbulence_cfg.get(
'rans')
12138 wall_cfg = turbulence_cfg.get(
'wall_function')
12142 if isinstance(les_cfg, dict):
12144 model_value = les_cfg.get(
'model',
'constant_smagorinsky')
12146 control_lines.append(f
"-les {les_code}")
12148 elif les_cfg
is not None:
12150 control_lines.append(f
"-les {les_code}")
12152 if isinstance(rans_cfg, dict):
12154 model_value = rans_cfg.get(
'model',
'k_omega')
12156 control_lines.append(f
"-rans {rans_code}")
12157 elif rans_cfg
is not None:
12159 control_lines.append(f
"-rans {rans_code}")
12161 if les_code
and rans_code:
12162 raise ValueError(
"models.physics.turbulence cannot enable both LES and RANS in the same case.")
12164 if isinstance(wall_cfg, dict):
12169 control_lines.append(f
"-wallfunction {wall_model if enabled else 0}")
12170 if 'roughness_height' in wall_cfg:
12171 control_lines.append(f
"-wall_roughness {format_flag_value(wall_cfg['roughness_height'])}")
12172 elif wall_cfg
is not None:
12173 control_lines.append(f
"-wallfunction {format_flag_value(wall_cfg)}")
12177 @brief Guard a value that is written verbatim into the generated control file.
12179 @details PETSc reads the control file line by line, so a value containing a newline
12180 writes additional option lines - which defeats every key-based check by
12181 smuggling a whole new flag. Rejecting the value is the only safe handling;
12182 there is no quoting that makes a multi-line option meaningful.
12183 @param[in] value Configured value destined for a control line.
12184 @param[in] context YAML path, for the error message.
12185 @return The value unchanged when it is safe to emit.
12186 @throws SystemExit when the value would inject an option line.
12189 if any(character
in text
for character
in (
"\n",
"\r")):
12191 f
"[FATAL] {context} contains a newline. Values written to the generated control "
12192 f
"file must occupy a single line; a multi-line value would inject additional "
12202 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
12203 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
12204 is skipped. All other values are emitted as "<flag> <value>".
12205 @param[out] control_lines The destination list of control-file lines.
12206 @param[in] options Mapping of raw CLI flags to values.
12210 for flag, value
in options.items():
12211 if isinstance(flag, str)
and flag.strip()
in RESERVED_DIRECTORY_FLAGS:
12215 f
"Refusing to emit reserved directory flag '{flag.strip()}' from passthrough "
12216 "options; run directories are fixed by the workspace contract."
12218 if isinstance(value, bool):
12220 control_lines.append(str(flag))
12222 control_lines.append(
12223 f
"{flag} {control_value(format_flag_value(value), f'passthrough option {flag}')}"
12227SOLVER_MONITORING_POISSON_FLAG_MAP = {
12228 "pic_true_residual":
"-ps_ksp_pic_monitor_true_residual",
12229 "true_residual":
"-ps_ksp_monitor_true_residual",
12230 "converged_reason":
"-ps_ksp_converged_reason",
12231 "view":
"-ps_ksp_view",
12234SOLVER_MONITORING_MOMENTUM_FLAG_MAP = {
12235 "newton_krylov_history":
"-mom_nk_pic_monitor",
12236 "snes_monitor":
"-mom_nk_snes_monitor",
12237 "snes_converged_reason":
"-mom_nk_snes_converged_reason",
12238 "ksp_monitor":
"-mom_nk_ksp_monitor",
12239 "ksp_converged_reason":
"-mom_nk_ksp_converged_reason",
12245 @brief Resolve human-readable solver monitoring YAML to raw control flags.
12246 @param[in] monitor_cfg Parsed monitor.yml mapping.
12247 @return Mapping of raw C/PETSc flags to values.
12249 solver_mon_cfg = monitor_cfg.get(
"solver_monitoring", {})
if isinstance(monitor_cfg, dict)
else {}
12250 if solver_mon_cfg
is None:
12252 if not isinstance(solver_mon_cfg, dict):
12253 raise ValueError(
"monitor.solver_monitoring must be a mapping when provided.")
12257 momentum_cfg = solver_mon_cfg.get(
"momentum", {})
12258 if momentum_cfg
is None:
12260 if not isinstance(momentum_cfg, dict):
12261 raise ValueError(
"monitor.solver_monitoring.momentum must be a mapping when provided.")
12262 unknown_momentum = sorted(set(momentum_cfg.keys()) - set(SOLVER_MONITORING_MOMENTUM_FLAG_MAP.keys()))
12263 if unknown_momentum:
12264 raise ValueError(f
"monitor.solver_monitoring.momentum has unsupported key(s): {unknown_momentum}.")
12265 for key, flag
in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.items():
12266 if key
in momentum_cfg:
12267 value = momentum_cfg[key]
12268 if not isinstance(value, bool):
12269 raise ValueError(f
"monitor.solver_monitoring.momentum.{key} must be boolean.")
12270 flags[flag] = value
12272 poisson_cfg = solver_mon_cfg.get(
"poisson", {})
12273 if poisson_cfg
is None:
12275 if not isinstance(poisson_cfg, dict):
12276 raise ValueError(
"monitor.solver_monitoring.poisson must be a mapping when provided.")
12277 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
12278 if unknown_poisson:
12279 raise ValueError(f
"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
12280 for key, flag
in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
12281 if key
in poisson_cfg:
12282 value = poisson_cfg[key]
12283 if not isinstance(value, bool):
12284 raise ValueError(f
"monitor.solver_monitoring.poisson.{key} must be boolean.")
12285 flags[flag] = value
12287 passthrough = solver_mon_cfg.get(
"petsc_passthrough_options", {})
12288 if passthrough
is None:
12290 if not isinstance(passthrough, dict):
12291 raise ValueError(
"monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
12292 flags.update(passthrough)
12296 for key, value
in solver_mon_cfg.items()
12297 if isinstance(key, str)
and key.startswith(
"-")
12299 flags.update(legacy_raw)
12301 unknown_top = sorted(
12303 for key
in solver_mon_cfg.keys()
12304 if key
not in {
"momentum",
"poisson",
"petsc_passthrough_options"}
and not (isinstance(key, str)
and key.startswith(
"-"))
12308 "monitor.solver_monitoring has unsupported key(s): "
12309 f
"{unknown_top}. Use 'momentum'/'poisson' for structured monitors or "
12310 "'petsc_passthrough_options' for raw PETSc flags."
12318 @brief Return the effective particle-console snapshot cadence from monitor.yml.
12319 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
12320 @return Value returned by `resolve_particle_console_output_frequency()`.
12322 if 'particle_console_output_frequency' in io_cfg:
12323 return io_cfg[
'particle_console_output_frequency']
12324 return io_cfg.get(
'data_output_frequency')
12328 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
12329 @param[in] case_cfg The parsed case.yml configuration dictionary.
12330 @param[out] control_lines A list of strings to which C-flags will be appended.
12332 models = case_cfg.get(
'models', {})
12334 'domain': {
'blocks':
'-nblk'},
12335 'physics.fsi': {
'immersed':
'-imm',
'moving_fsi':
'-fsi'},
12336 'physics.particles': {
'count':
'-numParticles'},
12338 for section_path, flags
in FLAG_MAP.items():
12339 current_level = models
12341 for key
in section_path.split(
'.'): current_level = current_level[key]
12342 for yaml_key, flag
in flags.items():
12343 if yaml_key
in current_level:
12344 control_lines.append(f
"{flag} {format_flag_value(current_level[yaml_key])}")
12345 except KeyError:
continue
12349 if models.get(
'physics', {}).get(
'dimensionality') ==
'2D':
12350 control_lines.append(
"-TwoD 1")
12352 particles_cfg = models.get(
'physics', {}).get(
'particles', {})
12353 p_init_mode_str = particles_cfg.get(
'init_mode',
'Surface')
12355 control_lines.append(f
"-pinit {pinit_code}")
12356 print(f
" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
12358 if pinit_code == 2:
12359 point_cfg = particles_cfg.get(
'point_source', {})
12360 if not isinstance(point_cfg, dict):
12361 raise ValueError(
"models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
12363 psrc_x = float(point_cfg[
'x'])
12364 psrc_y = float(point_cfg[
'y'])
12365 psrc_z = float(point_cfg[
'z'])
12366 except (KeyError, TypeError, ValueError):
12367 raise ValueError(
"PointSource init_mode requires numeric point_source.{x,y,z} values.")
12368 control_lines.append(f
"-psrc_x {psrc_x}")
12369 control_lines.append(f
"-psrc_y {psrc_y}")
12370 control_lines.append(f
"-psrc_z {psrc_z}")
12371 print(f
" - Particle Point Source: ({psrc_x}, {psrc_y}, {psrc_z})")
12373 p_restart_mode = particles_cfg.get(
'restart_mode')
12375 p_restart_mode_normalized = str(p_restart_mode).lower()
12376 if p_restart_mode_normalized
not in PARTICLE_RESTART_MODES:
12377 raise ValueError(f
"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
12378 control_lines.append(f
"-particle_restart_mode \"{p_restart_mode}\"")
12383KRYLOV_KSP_TYPES = {
12384 "gmres",
"fgmres",
"lgmres",
"dgmres",
"pgmres",
"gcr",
12385 "cg",
"cgne",
"cgs",
"bcgs",
"ibcgs",
"fbcgs",
"fbcgsr",
"bcgsl",
12386 "tfqmr",
"tcqmr",
"minres",
"symmlq",
"cr",
"lsqr",
"pipecg",
"pipefgmres",
12391 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
12392 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
12393 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
12396 if 'operation_mode' in solver_cfg
and isinstance(solver_cfg[
'operation_mode'], dict):
12397 op_mode = solver_cfg[
'operation_mode']
12398 if 'eulerian_field_source' in op_mode:
12400 flags[
'-euler_field_source'] = f
"\"{normalized_source}\""
12401 if 'analytical_type' in op_mode
and op_mode.get(
'analytical_type')
is not None:
12403 flags[
'-analytical_type'] = f
"\"{normalized_analytical_type}\""
12404 if normalized_analytical_type ==
"UNIFORM_FLOW":
12405 uniform_flow_cfg = op_mode.get(
'uniform_flow', {})
12406 if not isinstance(uniform_flow_cfg, dict):
12407 raise ValueError(
"operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
12409 flags[
'-analytical_uniform_u'] = float(uniform_flow_cfg[
'u'])
12410 flags[
'-analytical_uniform_v'] = float(uniform_flow_cfg[
'v'])
12411 flags[
'-analytical_uniform_w'] = float(uniform_flow_cfg[
'w'])
12412 except KeyError
as exc:
12413 raise ValueError(f
"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.")
from exc
12414 except (TypeError, ValueError)
as exc:
12415 raise ValueError(
"operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.")
from exc
12417 verification_cfg = solver_cfg.get(
'verification', {})
12418 if verification_cfg:
12419 if not isinstance(verification_cfg, dict):
12420 raise ValueError(
"verification must be a mapping when provided.")
12421 sources_cfg = verification_cfg.get(
'sources', {})
12422 if not isinstance(sources_cfg, dict):
12423 raise ValueError(
"verification.sources must be a mapping when provided.")
12424 diff_cfg = sources_cfg.get(
'diffusivity')
12425 if diff_cfg
is not None:
12426 if not isinstance(diff_cfg, dict):
12427 raise ValueError(
"verification.sources.diffusivity must be a mapping.")
12429 flags[
'-verification_diffusivity_mode'] = f
"\"{str(diff_cfg['mode']).strip().lower()}\""
12430 flags[
'-verification_diffusivity_profile'] = f
"\"{str(diff_cfg['profile']).strip().upper()}\""
12431 flags[
'-verification_diffusivity_gamma0'] = float(diff_cfg[
'gamma0'])
12432 flags[
'-verification_diffusivity_slope_x'] = float(diff_cfg[
'slope_x'])
12433 except KeyError
as exc:
12434 raise ValueError(f
"verification.sources.diffusivity.{exc.args[0]} is required.")
from exc
12435 except (TypeError, ValueError)
as exc:
12436 raise ValueError(
"verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.")
from exc
12438 scalar_cfg = sources_cfg.get(
'scalar')
12439 if scalar_cfg
is not None:
12440 if not isinstance(scalar_cfg, dict):
12441 raise ValueError(
"verification.sources.scalar must be a mapping.")
12443 flags[
'-verification_scalar_mode'] = f
"\"{str(scalar_cfg['mode']).strip().lower()}\""
12444 flags[
'-verification_scalar_profile'] = f
"\"{str(scalar_cfg['profile']).strip().upper()}\""
12445 except KeyError
as exc:
12446 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
12448 scalar_numeric_keys = {
12449 'CONSTANT': (
'value',),
12450 'LINEAR_X': (
'phi0',
'slope_x'),
12451 'SIN_PRODUCT': (
'amplitude',
'kx',
'ky',
'kz'),
12453 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
12454 for key
in scalar_numeric_keys.get(profile, ()):
12456 flags[f
'-verification_scalar_{key}'] = float(scalar_cfg[key])
12457 except KeyError
as exc:
12458 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
12459 except (TypeError, ValueError)
as exc:
12460 raise ValueError(f
"verification.sources.scalar.{key} must be numeric.")
from exc
12462 transport_cfg = solver_cfg.get(
'scalar_transport', {})
12464 if not isinstance(transport_cfg, dict):
12465 raise ValueError(
"scalar_transport must be a mapping when provided.")
12467 'schmidt_number':
'-schmidt_number',
12468 'turbulent_schmidt_number':
'-turb_schmidt_number',
12470 unknown_transport_keys = sorted(set(transport_cfg.keys()) - set(transport_map.keys()))
12471 if unknown_transport_keys:
12473 f
"scalar_transport has unsupported key(s): {unknown_transport_keys}. "
12474 "Use 'schmidt_number' or 'turbulent_schmidt_number'."
12476 for key, flag
in transport_map.items():
12477 if key
in transport_cfg:
12479 value = float(transport_cfg[key])
12480 except (TypeError, ValueError)
as exc:
12481 raise ValueError(f
"scalar_transport.{key} must be numeric.")
from exc
12483 raise ValueError(f
"scalar_transport.{key} must be positive.")
12484 flags[flag] = value
12486 selected_solver =
None
12487 if 'strategy' in solver_cfg:
12488 s = solver_cfg[
'strategy']
12489 if 'central_diff' in s:
12492 if 'momentum_solver' in s:
12494 elif 'implicit' in s:
12495 raise ValueError(
"Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
12497 def _warn_inactive_absolute_tol(cfg: dict, where: str):
12499 @brief Warn when absolute_tol is set but cannot affect convergence.
12501 absolute_tol bounds the velocity update. Since |dU| ~ dtau*|R|, bounding it
12502 absolutely is a disguised, step-size-dependent residual bound, so it takes no
12503 part once a residual tolerance is active -- which is now the default. Setting it
12504 therefore has no effect, and silently ineffective knobs are what this warning
12505 exists to prevent. See docs/pages/24_Dual_Time_Picard_Jameson_RK.md.
12506 @param[in] cfg Tolerance mapping to inspect for `absolute_tol` and the residual keys.
12507 @param[in] where Config path used to locate the offending key in the warning text.
12508 @return None; emits a warning on stderr when `absolute_tol` cannot take effect.
12510 if 'absolute_tol' not in cfg:
12516 @brief Report whether a residual tolerance is explicitly disabled.
12517 @param[in] key Residual tolerance key to inspect.
12518 @return True when the key is present and non-positive.
12520 v = cfg.get(key,
None)
12522 return v
is not None and float(v) <= 0.0
12523 except (TypeError, ValueError):
12525 if _off(
'residual_absolute_tol')
and _off(
'residual_relative_tol'):
12528 f
"[WARNING] {where}.absolute_tol is set but takes no part in convergence while a "
12529 "residual tolerance is active (the default). It is retained only for the legacy "
12530 "update-only branch, which can converge falsely when dtau collapses. To control "
12531 "accuracy use residual_relative_tol / residual_absolute_tol. See "
12532 "docs/pages/24_Dual_Time_Picard_Jameson_RK.md.",
12536 ms = solver_cfg.get(
'momentum_solver', {})
12537 if selected_solver
is None:
12538 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
12539 flags[
'-mom_solver_type'] = f
"\"{selected_solver}\""
12541 if 'tolerances' in solver_cfg:
12542 t = solver_cfg[
'tolerances']
12544 'max_iterations':
'-mom_max_pseudo_steps',
12545 'absolute_tol':
'-mom_atol',
12546 'relative_tol':
'-mom_rtol',
12547 'residual_absolute_tol':
'-mom_resid_atol',
12548 'residual_relative_tol':
'-mom_resid_rtol',
12549 'step_tol':
'-imp_stol'
12551 for key, flag
in tol_map.items():
12553 flags[flag] = t[key]
12554 _warn_inactive_absolute_tol(t,
"tolerances")
12556 def _append_dualtime_options(cfg: dict):
12558 @brief Append dualtime options.
12559 @param[in] cfg Argument passed to `_append_dualtime_options()`.
12561 if 'max_pseudo_steps' in cfg:
12562 flags[
'-mom_max_pseudo_steps'] = cfg[
'max_pseudo_steps']
12563 if 'absolute_tol' in cfg:
12564 flags[
'-mom_atol'] = cfg[
'absolute_tol']
12565 _warn_inactive_absolute_tol(
12566 {**solver_cfg.get(
'tolerances', {}), **cfg},
12567 "momentum_solver.dual_time_picard_jameson_rk")
12568 if 'relative_tol' in cfg:
12569 flags[
'-mom_rtol'] = cfg[
'relative_tol']
12570 if 'step_tol' in cfg:
12571 flags[
'-imp_stol'] = cfg[
'step_tol']
12572 if 'pseudo_cfl' in cfg:
12573 pcfl = cfg[
'pseudo_cfl']
12574 if 'initial' in pcfl:
12575 flags[
'-pseudo_cfl'] = pcfl[
'initial']
12576 if 'minimum' in pcfl:
12577 flags[
'-min_pseudo_cfl'] = pcfl[
'minimum']
12578 if 'maximum' in pcfl:
12579 flags[
'-max_pseudo_cfl'] = pcfl[
'maximum']
12580 if 'growth_factor' in pcfl:
12581 flags[
'-pseudo_cfl_growth_factor'] = pcfl[
'growth_factor']
12582 if 'reduction_factor' in pcfl:
12583 flags[
'-pseudo_cfl_reduction_factor'] = pcfl[
'reduction_factor']
12584 if 'jameson_residual_noise_allowance_factor' in cfg:
12585 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'jameson_residual_noise_allowance_factor']
12586 elif 'rk4_residual_noise_allowance_factor' in cfg:
12587 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'rk4_residual_noise_allowance_factor']
12588 if 'ratio_ema_alpha' in cfg:
12589 flags[
'-mom_ratio_ema_alpha'] = cfg[
'ratio_ema_alpha']
12591 def _append_newton_krylov_options(cfg: dict):
12593 @brief Append validated structured Newton--Krylov PETSc options.
12594 @param[in] cfg Structured Newton--Krylov mapping.
12597 jacobian = cfg[
"jacobian"]
12598 flags[
"-mom_nk_jacobian_type"] = jacobian[
"type"]
12599 flags[
"-mom_nk_jacobian_fd_mode"] = jacobian[
"finite_difference"][
"mode"]
12600 preconditioner = cfg[
"preconditioner"]
12601 flags[
"-mom_nk_preconditioner_model"] = preconditioner[
"model"]
12602 flags[
"-mom_nk_preconditioner_structure"] = preconditioner[
"structure"][
"type"]
12603 nonlinear = cfg[
"nonlinear_solver"]
12605 "method":
"-mom_nk_snes_type",
12606 "absolute_tolerance":
"-mom_nk_snes_atol",
12607 "relative_tolerance":
"-mom_nk_snes_rtol",
12608 "step_tolerance":
"-mom_nk_snes_stol",
12609 "max_iterations":
"-mom_nk_snes_max_it",
12611 for key, flag
in nonlinear_map.items():
12612 if key
in nonlinear:
12613 flags[flag] = nonlinear[key]
12614 line_search = nonlinear.get(
"line_search", {})
12615 if "type" in line_search:
12616 flags[
"-mom_nk_snes_linesearch_type"] = line_search[
"type"]
12617 ew = nonlinear.get(
"eisenstat_walker")
12619 flags[
"-mom_nk_snes_ksp_ew"] = ew[
"enabled"]
12622 "version":
"-mom_nk_snes_ksp_ew_version",
12623 "initial_relative_tolerance":
"-mom_nk_snes_ksp_ew_rtol0",
12624 "maximum_relative_tolerance":
"-mom_nk_snes_ksp_ew_rtolmax",
12625 "gamma":
"-mom_nk_snes_ksp_ew_gamma",
12626 "exponent":
"-mom_nk_snes_ksp_ew_alpha",
12627 "safeguard_exponent":
"-mom_nk_snes_ksp_ew_alpha2",
12628 "safeguard_threshold":
"-mom_nk_snes_ksp_ew_threshold",
12630 for key, flag
in ew_map.items():
12632 flags[flag] = ew[key]
12634 linear = cfg[
"linear_solver"]
12636 "method":
"-mom_nk_ksp_type",
12637 "absolute_tolerance":
"-mom_nk_ksp_atol",
12638 "relative_tolerance":
"-mom_nk_ksp_rtol",
12639 "max_iterations":
"-mom_nk_ksp_max_it",
12641 for key, flag
in linear_map.items():
12643 flags[flag] = linear[key]
12644 gmres = linear.get(
"gmres", {})
12645 if "restart" in gmres:
12646 flags[
"-mom_nk_ksp_gmres_restart"] = gmres[
"restart"]
12648 if isinstance(ms, dict):
12649 allowed_ms_keys = {
'type',
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4',
'newton_krylov'}
12650 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
12651 if unknown_ms_keys:
12653 f
"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
12654 "Currently supported blocks: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
12657 if 'dual_time_picard_jameson_rk' in ms
and 'dual_time_picard_rk4' in ms:
12659 "Use only momentum_solver.dual_time_picard_jameson_rk; "
12660 "do not also set its deprecated dual_time_picard_rk4 alias."
12662 dt_picard_cfg = ms.get(
'dual_time_picard_jameson_rk', ms.get(
'dual_time_picard_rk4'))
12663 if dt_picard_cfg
is not None:
12664 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK":
12666 f
"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
12668 if not isinstance(dt_picard_cfg, dict):
12669 raise ValueError(
"momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
12670 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
12671 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
12673 "Use only jameson_residual_noise_allowance_factor; "
12674 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
12676 _append_dualtime_options(dt_picard_cfg)
12677 newton_cfg = ms.get(
'newton_krylov')
12678 if newton_cfg
is not None:
12679 if selected_solver !=
"newton_krylov":
12681 f
"momentum_solver.newton_krylov is set but selected solver is {selected_solver}."
12683 _append_newton_krylov_options(newton_cfg)
12684 def _normalize_poisson_method(value) -> str:
12686 @brief Normalize a user-facing Poisson linear-solver method name.
12687 @param[in] value Method value from the solver YAML.
12688 @return Lowercase PETSc KSP method token.
12690 method = str(value).strip().lower()
12692 raise ValueError(
"poisson_solver.method cannot be empty.")
12695 def _normalize_poisson_preconditioner(value) -> str:
12697 @brief Normalize and validate the outer Poisson preconditioner name.
12698 @param[in] value Preconditioner value from the solver YAML.
12699 @return PETSc PC token for the supported outer preconditioner.
12701 pc = str(value).strip().lower()
12702 pc = POISSON_PRECONDITIONER_SPELLINGS.get(pc, pc)
12703 if pc
not in POISSON_PRECONDITIONER_TYPES:
12705 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
12706 "The runtime Poisson solver still assumes PETSc PCMG setup."
12710 def _warn_if_krylov_coarse_solver(ksp_type, source_key: str):
12712 @brief Warn when the coarsest multigrid level is given a Krylov solver.
12713 @param[in] ksp_type PETSc KSP token configured for `level_0`.
12714 @param[in] source_key Name of the source YAML block, used in the message.
12715 @details level_0 is the coarse solve at the base of the V-cycle, not a
12716 smoother. A Krylov method there makes the multigrid
12717 preconditioner a nonlinear operator, which decouples the outer
12718 KSP's tracked residual from the true residual b-Ax. This stays a
12719 warning rather than an error because it remains legitimate at
12720 large scale when tolerances are set against the true residual.
12722 if str(ksp_type).strip().lower()
not in KRYLOV_KSP_TYPES:
12725 f
"[WARNING] {source_key}.multigrid.level_solvers.level_0.method = '{ksp_type}' "
12726 "is a Krylov method. level_0 is the multigrid COARSE SOLVE, not a smoother, "
12727 "so a Krylov method there makes the preconditioner nonlinear and the outer "
12728 "KSP's tracked residual can stop matching the true residual b-Ax. "
12729 "Prefer {method: preonly, preconditioner: redundant}. If this is deliberate, "
12730 "enable solver_monitoring.poisson.pic_true_residual and set tolerances against "
12731 "the true residual. See docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.",
12735 def _poisson_level_number(level_name) -> int:
12737 @brief Extract the numeric suffix from a `level_N` multigrid level key.
12738 @param[in] level_name YAML level key supplied by the user.
12739 @return Numeric level suffix.
12741 text = str(level_name).strip()
12742 match = re.fullmatch(
r"level_(\d+)", text)
12744 raise ValueError(f
"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
12745 return int(match.group(1))
12747 def _append_poisson_solver_flags(ps: dict, source_key: str):
12749 @brief Append structured Poisson solver options to the flat PETSc flag map.
12750 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
12751 @param[in] source_key Name of the source YAML block, used in error messages.
12753 if not isinstance(ps, dict):
12754 raise ValueError(f
"{source_key} must be a mapping when provided.")
12758 method = _normalize_poisson_method(ps[
'method'])
12759 flags[
'-ps_ksp_type'] = method
12760 if 'absolute_tolerance' in ps:
12761 flags[
'-ps_ksp_atol'] = ps[
'absolute_tolerance']
12762 flags[
'-poisson_tol'] = ps[
'absolute_tolerance']
12763 if 'relative_tolerance' in ps:
12764 flags[
'-ps_ksp_rtol'] = ps[
'relative_tolerance']
12765 if 'max_iterations' in ps:
12766 flags[
'-ps_ksp_max_it'] = ps[
'max_iterations']
12767 if 'tolerance' in ps:
12768 flags[
'-poisson_tol'] = ps[
'tolerance']
12770 gmres_cfg = ps.get(
'gmres', {})
12771 if gmres_cfg
is not None:
12772 if not isinstance(gmres_cfg, dict):
12773 raise ValueError(f
"{source_key}.gmres must be a mapping when provided.")
12774 if 'restart' in gmres_cfg:
12776 method = _normalize_poisson_method(ps.get(
'method',
'fgmres'))
12777 flags.setdefault(
'-ps_ksp_type', method)
12778 if method
not in GMRES_RESTART_METHODS:
12780 f
"{source_key}.gmres.restart is valid only when {source_key}.method "
12781 "is one of 'gmres', 'fgmres', or 'lgmres'."
12783 flags[
'-ps_ksp_gmres_restart'] = gmres_cfg[
'restart']
12785 preconditioner_cfg = ps.get(
'preconditioner', {})
12786 if preconditioner_cfg:
12787 if not isinstance(preconditioner_cfg, dict):
12788 raise ValueError(f
"{source_key}.preconditioner must be a mapping when provided.")
12789 if 'type' in preconditioner_cfg:
12790 flags[
'-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg[
'type'])
12792 if 'multigrid' in ps:
12793 mg = ps[
'multigrid']
12794 if not isinstance(mg, dict):
12795 raise ValueError(f
"{source_key}.multigrid must be a mapping when provided.")
12796 mg_map = {
'levels':
'-mg_level',
'pre_sweeps':
'-mg_pre_it',
'post_sweeps':
'-mg_post_it'}
12797 for key, flag
in mg_map.items():
12798 if key
in mg: flags[flag] = mg[key]
12800 cycle = str(mg[
'cycle']).strip().lower()
12801 if cycle
not in {
"v"}:
12802 raise ValueError(f
"{source_key}.multigrid.cycle currently supports only 'v'.")
12804 mode = str(mg[
'mode']).strip().lower()
12805 if mode
not in {
"multiplicative"}:
12806 raise ValueError(f
"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
12807 if 'semi_coarsening' in mg:
12808 sc = mg[
'semi_coarsening']
12809 if not isinstance(sc, dict):
12810 raise ValueError(f
"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
12814 if 'level_solvers' in mg:
12815 level_solvers = mg[
'level_solvers']
12816 if not isinstance(level_solvers, dict):
12817 raise ValueError(f
"{source_key}.multigrid.level_solvers must be a mapping when provided.")
12818 for level_name, settings
in level_solvers.items():
12819 if not isinstance(settings, dict):
12820 raise ValueError(f
"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
12821 level_num = _poisson_level_number(level_name)
12822 for key, value
in settings.items():
12823 mapped_key = {
'method':
'ksp_type',
'preconditioner':
'pc_type'}.get(key, key)
12826 prefix =
"-ps_mg_coarse_"
12827 if mapped_key ==
'ksp_type':
12828 _warn_if_krylov_coarse_solver(value, source_key)
12830 prefix = f
"-ps_mg_levels_{level_num}_"
12833 if 'poisson_solver' in solver_cfg
and 'pressure_solver' in solver_cfg:
12834 if solver_cfg[
'poisson_solver'] != solver_cfg[
'pressure_solver']:
12836 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
12837 "Use 'poisson_solver' only, or make the legacy alias identical."
12839 poisson_cfg = solver_cfg.get(
'poisson_solver', solver_cfg.get(
'pressure_solver'))
12840 if poisson_cfg
is not None:
12841 source_key =
'poisson_solver' if 'poisson_solver' in solver_cfg
else 'pressure_solver'
12842 _append_poisson_solver_flags(poisson_cfg, source_key)
12843 interp_cfg = solver_cfg.get(
'interpolation', {})
12844 if isinstance(interp_cfg, dict):
12845 interp_method_str = interp_cfg.get(
'method',
'Trilinear')
12847 interp_method_str =
'Trilinear'
12849 flags[
'-interpolation_method'] = interp_code
12850 print(f
" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
12852 if 'petsc_passthrough_options' in solver_cfg:
12853 passthrough = solver_cfg[
'petsc_passthrough_options']
12854 if passthrough
is None:
12856 if not isinstance(passthrough, dict):
12857 raise ValueError(
"petsc_passthrough_options must be a mapping when provided.")
12859 for key, value
in passthrough.items():
12860 if str(key).strip() ==
"-ps_mg_coarse_ksp_type":
12861 _warn_if_krylov_coarse_solver(value,
"petsc_passthrough_options")
12864 if '-ps_ksp_type' in flags:
12865 summary_bits.append(f
"method={flags['-ps_ksp_type']}")
12866 if '-ps_ksp_atol' in flags:
12867 summary_bits.append(f
"atol={flags['-ps_ksp_atol']}")
12868 if '-ps_ksp_rtol' in flags:
12869 summary_bits.append(f
"rtol={flags['-ps_ksp_rtol']}")
12870 if '-ps_ksp_max_it' in flags:
12871 summary_bits.append(f
"max_it={flags['-ps_ksp_max_it']}")
12872 if '-mg_level' in flags:
12873 summary_bits.append(f
"mg_levels={flags['-mg_level']}")
12875 print(f
" - Poisson Solver: {', '.join(summary_bits)}")
12876 if selected_solver ==
"DUALTIME_PICARD_JAMESON_RK":
12878 for label, flag
in (
12879 (
"initial_pseudo_cfl",
"-pseudo_cfl"),
12880 (
"pseudo_cfl_range",
None),
12881 (
"max_pseudo_steps",
"-mom_max_pseudo_steps"),
12883 if flag
and flag
in flags:
12884 dualtime_bits.append(f
"{label}={flags[flag]}")
12885 elif label ==
"pseudo_cfl_range" and "-min_pseudo_cfl" in flags
and "-max_pseudo_cfl" in flags:
12886 dualtime_bits.append(f
"pseudo_cfl_range=[{flags['-min_pseudo_cfl']}, {flags['-max_pseudo_cfl']}]")
12887 print(
" - Momentum Solver: Dual Time Picard Jameson RK" +
12888 (f
" ({', '.join(dualtime_bits)})" if dualtime_bits
else ""))
12889 elif selected_solver ==
"newton_krylov":
12891 for label, flag
in (
12892 (
"jacobian",
"-mom_nk_jacobian_type"),
12893 (
"nonlinear",
"-mom_nk_snes_type"),
12894 (
"linear",
"-mom_nk_ksp_type"),
12895 (
"preconditioner",
"-mom_nk_preconditioner_model"),
12898 newton_bits.append(f
"{label}={flags[flag]}")
12899 print(
" - Momentum Solver: Newton Krylov" +
12900 (f
" ({', '.join(newton_bits)})" if newton_bits
else " (PETSc defaults)"))
12902 print(
" - Momentum Solver: Explicit RK (no pseudo-time controller)")
12906 restart_source_dir=None, continue_mode=False,
12907 config_dir: str =
None):
12909 @brief Generates the main .control file for the C-solver.
12910 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
12911 into a single, machine-readable file of command-line flags.
12912 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
12913 @param[in] run_id Argument passed to `generate_solver_control_file()`.
12914 @param[in] configs Argument passed to `generate_solver_control_file()`.
12915 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
12916 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
12917 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
12918 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
12919 @param[in] config_dir Optional configuration revision directory.
12920 @return Value returned by `generate_solver_control_file()`.
12922 print(
"[INFO] Generating master solver control file...")
12923 case_cfg, solver_cfg, monitor_cfg = configs[
'case'], configs[
'solver'], configs[
'monitor']
12924 source_files = {
'Case': configs[
'case_path'],
'Solver': configs[
'solver_path'],
'Monitor': configs[
'monitor_path']}
12928 props, run_ctrl = case_cfg[
'properties'], case_cfg[
'run_control']
12929 scales, fluid, ic = props[
'scaling'], props[
'fluid'], props[
'initial_conditions']
12932 L_ref = fluid_scaling[
"length_ref"]
12933 U_ref = fluid_scaling[
"velocity_ref"]
12934 rho = fluid_scaling[
"density"]
12935 reynolds = fluid_scaling[
"reynolds"]
12936 dt_phys = float(run_ctrl[
'dt_physical'])
12937 T_ref = L_ref / U_ref
if U_ref != 0
else float(
'inf')
12938 dt_nondim = dt_phys / T_ref
if T_ref != float(
'inf')
else 0.0
12939 print(f
" - Reynolds Number (Re) = {reynolds:.4f}")
12940 print(f
" - Non-Dimensional dt* = {dt_nondim:.6f}")
12942 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
12944 start_step = int(run_ctrl.get(
"start_step", 0)
or 0)
12945 ic_is_authoritative = eulerian_source ==
"solve" and start_step == 0
12947 if ic_is_authoritative:
12949 ic, prepared_blocks, U_ref,
12950 provider_context={
"kinematic_viscosity": fluid_scaling[
"nondimensional_kinematic_viscosity"]},
12952 finit_mode_str = resolved_ic[
"label"]
12953 finit_code = resolved_ic[
"finit"]
12954 ic_params = resolved_ic[
"cli_params"]
12955 print(f
" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
12956 if "ucont_x" in ic_params:
12958 f
"-ucont_x {ic_params['ucont_x']}",
12959 f
"-ucont_y {ic_params['ucont_y']}",
12960 f
"-ucont_z {ic_params['ucont_z']}",
12962 if "ic_velocity_physical" in ic_params:
12963 ic_cli.append(f
"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
12964 if "flow_direction" in ic_params:
12965 ic_cli.append(f
"-flow_direction {ic_params['flow_direction']}")
12968 f
"[WARN] Ignoring configured initial condition because "
12969 f
"eulerian_field_source={eulerian_source!r} and start_step={start_step} select another source.",
12974 control_lines.extend([
12975 f
"-start_step {run_ctrl['start_step']}", f
"-totalsteps {run_ctrl['total_steps']}",
12976 f
"-ren {reynolds}", f
"-dt {dt_nondim}", f
"-finit {finit_code}",
12978 f
"-scaling_L_ref {L_ref}", f
"-scaling_U_ref {U_ref}", f
"-scaling_rho_ref {rho}"
12980 except (KeyError, TypeError, ZeroDivisionError, ValueError)
as e:
12981 print(f
"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
12985 if monitor_files.get(
"whitelist"):
12986 control_lines.append(f
"-whitelist_config_file {monitor_files['whitelist']}")
12987 if monitor_files.get(
"profile"):
12988 control_lines.append(f
"-profile_config_file {monitor_files['profile']}")
12991 profiling_cfg = monitor_files.get(
"profiling", {})
12992 control_lines.append(f
"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
12993 if profiling_cfg.get(
"mode") !=
"off":
12994 control_lines.append(f
"-profiling_timestep_file {profiling_cfg.get('timestep_file', 'Profiling_Timestep_Summary.csv')}")
12995 control_lines.append(f
"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
12997 memory_log_cfg = diagnostics_cfg[
"runtime_memory_log"]
12998 control_lines.append(f
"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
12999 control_lines.append(f
"-runtime_memory_log_file {memory_log_cfg.get('file', 'Runtime_Memory.log')}")
13001 walltime_guard_policy = configs.get(
"walltime_guard_policy")
13002 if walltime_guard_policy
is not None:
13003 control_lines.extend(
13005 f
"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
13006 f
"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
13007 f
"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
13008 f
"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
13009 f
"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
13013 grid_cfg = case_cfg.get(
'grid', {})
13014 grid_mode = grid_cfg.get(
'mode')
13015 expected_nblk = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
13017 if grid_mode ==
'file':
13018 print(
"[INFO] Grid Mode: Using external file...")
13019 case_file_dir = os.path.dirname(configs[
'case_path'])
13020 nondim_grid_path = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
13021 if os.path.isfile(nondim_grid_path):
13022 print(f
"[INFO] Reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
13023 control_lines.append(f
"-grid_file {nondim_grid_path}")
13026 grid_for_validation = source_grid
13029 grid_for_validation, nondim_grid_path, L_ref, expected_nblk=expected_nblk
13032 f
"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
13033 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
13035 control_lines.append(f
"-grid_file {nondim_grid_path}")
13036 except Exception
as e:
13037 print(f
"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
13039 elif grid_mode ==
'grid_gen':
13040 print(
"[INFO] Grid Mode: Generating external grid via grid.gen...")
13041 nondim_grid_path = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
13042 if os.path.isfile(nondim_grid_path):
13043 print(f
"[INFO] Reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
13044 control_lines.append(f
"-grid_file {nondim_grid_path}")
13049 configs[
'case_path'], run_dir, grid_cfg, case_cfg=case_cfg)
13051 generated_grid, nondim_grid_path, L_ref, expected_nblk=expected_nblk
13054 f
"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
13055 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
13057 control_lines.append(f
"-grid_file {nondim_grid_path}")
13058 except Exception
as e:
13059 print(f
"[FATAL] Grid generation failed: {e}", file=sys.stderr)
13061 elif grid_mode ==
'programmatic_c':
13062 print(
"[INFO] Grid Mode: Programmatic C...")
13064 control_lines.append(
"-grid")
13065 for p_key
in GRID_DA_PROCESSOR_KEYS:
13066 grid_settings.pop(p_key,
None)
13067 for key, value
in grid_settings.items(): control_lines.append(f
"-{key} {format_flag_value(value)}")
13075 nondim_grid_path = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
13076 if not os.path.isfile(nondim_grid_path):
13079 grid_cfg.get(
'programmatic_settings', {}), nondim_grid_path, L_ref
13082 "[SUCCESS] Materialized a bridge grid for the Python initial-condition "
13083 f
"provider: {os.path.relpath(nondim_grid_path)}"
13085 except Exception
as e:
13086 print(f
"[FATAL] Failed to materialize bridge grid for initial-condition generator: {e}",
13090 raise ValueError(f
"Unknown or missing grid mode '{grid_mode}' in case.yml.")
13095 except Exception
as e:
13096 print(f
"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
13098 control_lines.extend([
13099 f
"-ic_field {resolved_ic['field_code']}",
13100 f
"-ic_dir {staged_ic['directory']}",
13102 print(f
" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
13106 run_dir, run_id, case_cfg, source_files, config_dir=config_dir
13108 except ValueError
as e:
13109 print(f
"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
13111 control_lines.append(f
"-bcs_files \"{','.join(bcs_files)}\"")
13117 if 'solver_parameters' in case_cfg:
13118 params = case_cfg[
'solver_parameters']
13120 for key, value
in params.items():
13121 control_lines.append(f
"{key} {format_flag_value(value)}")
13125 except ValueError
as e:
13126 print(f
"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
13132 except ValueError
as e:
13133 print(f
"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
13137 io_cfg = monitor_cfg.get(
'io', {})
13139 if 'data_output_frequency' in io_cfg: control_lines.append(f
"-tio {io_cfg['data_output_frequency']}")
13140 if particle_console_output_freq
is not None:
13141 control_lines.append(f
"-particle_console_output_freq {particle_console_output_freq}")
13143 if statistics_console_output_freq
is not None:
13144 control_lines.append(f
"-statistics_console_output_freq {statistics_console_output_freq}")
13145 if 'particle_log_interval' in io_cfg: control_lines.append(f
"-logfreq {io_cfg['particle_log_interval']}")
13146 if restart_source_dir:
13147 expected_restart = os.path.abspath(
13148 os.path.join(run_dir, CANONICAL_RUN_PATHS[
"restart"])
13150 if os.path.abspath(restart_source_dir) != expected_restart:
13152 "Restart data was not materialized in the canonical run input: "
13153 f
"expected {expected_restart}, got {restart_source_dir}."
13156 control_lines.append(
"-continue_mode true")
13157 elif str(configs.get(
"statistics_state",
"reset")).lower() ==
"carry":
13158 control_lines.append(
"-field_statistics_continue true")
13164 control_lines.append(
"")
13165 control_lines.append(
"# Canonical run-owned directories are fixed by the workspace contract.")
13166 control_lines.append(f
"-output_dir {CANONICAL_RUN_PATHS['output']}")
13167 control_lines.append(f
"-restart_dir {CANONICAL_RUN_PATHS['restart']}")
13168 control_lines.append(f
"-log_dir {CANONICAL_RUN_PATHS['logs']}")
13169 control_lines.append(f
"-analysis_dir {CANONICAL_RUN_PATHS['metrics']}")
13171 final_content =
generate_header(run_id, source_files) +
"\n".join(control_lines)
13172 config_dir = config_dir
or os.path.join(run_dir,
"config")
13173 os.makedirs(config_dir, exist_ok=
True)
13174 control_file_path = os.path.join(config_dir, f
"{run_id}.control")
13175 with open(control_file_path,
"w")
as f: f.write(final_content)
13176 print(f
"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
13177 return os.path.abspath(control_file_path)
13181 @brief Generates a key=value config file (post.run) for the C post-processor.
13182 @details Translates the structured post-processing YAML into the specific flat
13183 key-value format required by the C executable, including complex,
13184 semicolon-separated pipeline strings.
13185 @param[in] run_dir The path to the main run directory.
13186 @param[in] run_id The unique identifier for the run.
13187 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
13188 @param[in] source_files A dictionary of source files for the header.
13189 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
13190 @return The absolute path to the generated post.run recipe file.
13192 print(
"[INFO] Generating post-processor recipe file (post.run)...")
13193 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
13196 os.makedirs(config_dir, exist_ok=
True)
13197 post_recipe_path = os.path.join(config_dir,
"post.run")
13202 for key, value
in c_config.items():
13203 if value
is not None and str(value) !=
"":
13204 lines.append(f
"{key} = {value}")
13206 with open(post_recipe_path,
"w")
as f:
13207 f.write(
"\n".join(lines))
13208 print(f
"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
13209 return os.path.abspath(post_recipe_path)
13213 @brief Executes a command, streaming its output to the console and a log file.
13215 If None, the process inherits the parent's environment directly.
13216 @param[in] command Argument passed to `execute_command()`.
13217 @param[in] run_dir Argument passed to `execute_command()`.
13218 @param[in] log_filename Argument passed to `execute_command()`.
13219 @param[in] monitor_cfg Argument passed to `execute_command()`.
13222 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
13224 print(f
"[INFO] Launching Command...\n > {format_command_for_display(command)}")
13225 print(f
" Log file: {os.path.relpath(log_path)}")
13230 "stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
13231 "cwd": run_dir,
"bufsize": 1,
"universal_newlines":
True,
13232 "encoding":
'utf-8',
"errors":
'replace'
13236 print(
"[INFO] Creating custom environment to set LOG_LEVEL.")
13237 run_env = os.environ.copy()
13238 verbosity = monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()
13239 run_env[
'LOG_LEVEL'] = verbosity
13240 print(f
"[INFO] Setting LOG_LEVEL={verbosity} for C executable.")
13241 popen_kwargs[
'env'] = run_env
13243 print(
"[INFO] Using inherited environment for process.")
13248 process = subprocess.Popen(command, **popen_kwargs)
13250 with open(log_path,
"w")
as log_file:
13251 for line
in process.stdout:
13252 sys.stdout.write(line)
13253 log_file.write(line)
13255 return_code = process.returncode
13257 if return_code == 0:
13258 print(f
"[SUCCESS] Execution finished successfully.")
13260 print(f
"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
13261 sys.exit(return_code)
13262 except FileNotFoundError:
13263 print(f
"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
13264 print(
" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
13266 except Exception
as e:
13267 print(f
"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
13273 @brief Render a shell-safe command string for console and log output.
13274 @param[in] command Argument passed to `format_command_for_display()`.
13275 @return Value returned by `format_command_for_display()`.
13277 return " ".join(shlex.quote(str(part))
for part
in command)
13282 @brief Resolve a command log filename relative to the run directory.
13283 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
13284 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
13285 @return Value returned by `resolve_command_log_path()`.
13287 if os.path.dirname(log_filename):
13288 return os.path.join(run_dir, log_filename)
13289 return os.path.join(run_dir,
"logs", log_filename)
13294 @brief Raised when an external command exits unsuccessfully.
13297 def __init__(self, command: list, returncode: int, details: str =
None):
13299 @brief Initialize a command execution error.
13300 @param[in] command Argument passed to `__init__()`.
13301 @param[in] returncode Argument passed to `__init__()`.
13302 @param[in] details Argument passed to `__init__()`.
13307 detail_suffix = f
": {details}" if details
else ""
13309 f
"Command failed with exit code {returncode}: {format_command_for_display(command)}{detail_suffix}"
13315 @brief Raised when plot.gen reports a missing optional dependency.
13321 @brief Run a command and capture combined stdout/stderr details for later inspection.
13322 @param[in] command Argument passed to `_run_captured_command()`.
13323 @param[in] run_dir Argument passed to `_run_captured_command()`.
13324 @return Value returned by `_run_captured_command()`.
13327 return subprocess.run(
13331 capture_output=
True,
13336 except FileNotFoundError
as exc:
13337 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
13342 @brief Raise `CommandExecutionError` when a captured command failed.
13343 @param[in] command Argument passed to `_require_successful_command()`.
13344 @param[in] result Argument passed to `_require_successful_command()`.
13346 if result.returncode == 0:
13348 details = (result.stderr
or result.stdout).strip()
13354 @brief Run a command, require success, and return stripped stdout text.
13355 @param[in] command Argument passed to `_capture_command_stdout()`.
13356 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
13357 @return Value returned by `_capture_command_stdout()`.
13361 return result.stdout.strip()
13366 @brief Stream command output to stdout and an already-open log file.
13367 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
13368 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
13369 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
13372 print(f
"[INFO] Running: {display}")
13373 log_file.write(f
"$ {display}\n")
13377 "stdout": subprocess.PIPE,
13378 "stderr": subprocess.STDOUT,
13381 "universal_newlines":
True,
13382 "encoding":
"utf-8",
13383 "errors":
"replace",
13387 process = subprocess.Popen(command, **popen_kwargs)
13388 except FileNotFoundError
as exc:
13389 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
13392 for line
in process.stdout:
13393 sys.stdout.write(line)
13394 log_file.write(line)
13395 return_code = process.wait()
13396 log_file.write(
"\n")
13398 if return_code != 0:
13404 @brief Capture the current git HEAD branch name and commit hash.
13405 @param[in] run_dir Argument passed to `_get_git_head_state()`.
13406 @return Value returned by `_get_git_head_state()`.
13409 branch_result =
_run_captured_command([
"git",
"symbolic-ref",
"--quiet",
"--short",
"HEAD"], run_dir)
13410 branch_name = branch_result.stdout.strip()
if branch_result.returncode == 0
else None
13411 return {
"branch": branch_name,
"commit": head_commit}
13416 @brief Return local branch names plus their configured upstreams.
13417 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
13418 @return Value returned by `_get_local_branches_with_upstreams()`.
13421 [
"git",
"for-each-ref",
"--sort=refname",
"--format=%(refname:short)\t%(upstream:short)",
"refs/heads"],
13425 for line
in output.splitlines():
13426 if not line.strip():
13428 branch_name, _, upstream_name = line.partition(
"\t")
13429 branches.append((branch_name, upstream_name
or None))
13435 @brief Return `True` when the repository has staged or unstaged tracked changes.
13436 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
13437 @return Value returned by `_working_tree_has_tracked_changes()`.
13439 command = [
"git",
"status",
"--porcelain",
"--untracked-files=no"]
13442 return bool(result.stdout.strip())
13447 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
13448 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
13449 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
13450 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
13452 cleanup_command = [
"git",
"rebase",
"--abort"]
if rebase
else [
"git",
"merge",
"--abort"]
13454 if result.returncode == 0:
13455 print(f
"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
13456 log_file.write(f
"$ {format_command_for_display(cleanup_command)}\n")
13458 sys.stdout.write(result.stdout)
13459 log_file.write(result.stdout)
13461 sys.stderr.write(result.stderr)
13462 log_file.write(result.stderr)
13463 log_file.write(
"\n")
13467 details = (result.stderr
or result.stdout).strip()
13470 f
"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
13472 print(message, file=sys.stderr)
13473 log_file.write(message +
"\n")
13479 @brief Restore the repository back to the branch or detached commit it started on.
13480 @param[in] run_dir Argument passed to `_restore_git_head()`.
13481 @param[in] original_head Argument passed to `_restore_git_head()`.
13482 @param[in] log_file Argument passed to `_restore_git_head()`.
13485 if original_head[
"branch"]:
13486 if current_state[
"branch"] == original_head[
"branch"]:
13491 if current_state[
"branch"]
is None and current_state[
"commit"] == original_head[
"commit"]:
13498 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
13499 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
13500 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
13501 @param[in] rebase Argument passed to `pull_all_source_branches()`.
13504 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
13506 print(
"\n" +
"="*23 +
" PULL SOURCE STAGE " +
"="*22)
13507 print(
"[INFO] Refreshing all local source branches that track an upstream.")
13508 print(f
" Log file: {os.path.relpath(log_path)}")
13514 raise RuntimeError(
13515 "Multi-branch pull requires a clean tracked working tree in the source repository. "
13516 "Commit or stash those changes first, or rerun with --current-branch-only."
13519 except (CommandExecutionError, RuntimeError)
as exc:
13520 print(f
"[FATAL] {exc}", file=sys.stderr)
13521 sys.exit(getattr(exc,
"returncode", 1))
13524 print(
"[FATAL] No local branches were found in the source repository.", file=sys.stderr)
13527 if original_head[
"branch"]:
13528 branches = [item
for item
in branches
if item[0] != original_head[
"branch"]] + [
13529 item
for item
in branches
if item[0] == original_head[
"branch"]
13532 skipped_branches = []
13533 current_operation =
None
13535 restore_error =
None
13537 with open(log_path,
"w", encoding=
"utf-8")
as log_file:
13538 log_file.write(f
"# PICurv pull-source all-branch sync\n")
13539 log_file.write(f
"# repository: {os.path.abspath(run_dir)}\n")
13540 log_file.write(f
"# started: {datetime.now().isoformat()}\n")
13542 f
"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
13546 for branch_name, upstream_name
in branches:
13547 if not upstream_name:
13548 warning = f
"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
13549 print(warning, file=sys.stderr)
13550 log_file.write(warning +
"\n")
13551 skipped_branches.append(branch_name)
13554 print(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
13555 log_file.write(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
13557 current_operation = f
"checkout:{branch_name}"
13560 pull_command = [
"git",
"pull"]
13562 pull_command.append(
"--rebase")
13563 current_operation = f
"pull:{branch_name}"
13565 current_operation =
None
13566 except CommandExecutionError
as exc:
13568 if current_operation
and current_operation.startswith(
"pull:"):
13573 except CommandExecutionError
as exc:
13574 restore_error = exc
13580 f
"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
13581 f
"Check log: {os.path.relpath(log_path)}",
13584 sys.exit(restore_error.returncode)
13586 f
"[FATAL] Multi-branch pull failed. Original branch restored. "
13587 f
"Check log: {os.path.relpath(log_path)}",
13590 sys.exit(pull_error.returncode)
13594 f
"[FATAL] Branch updates completed, but the original branch could not be restored. "
13595 f
"Check log: {os.path.relpath(log_path)}",
13598 sys.exit(restore_error.returncode)
13600 if skipped_branches:
13601 print(f
"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
13602 print(
"[SUCCESS] All local tracking branches are up to date.")
13606 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
13607 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
13608 @return Value returned by `auto_identify_run_inputs()`.
13610 run_dir = os.path.dirname(os.path.abspath(config_dir))
13613 case_path = active.get(
"case")
13614 monitor_path = active.get(
"monitor")
13615 solver_control_path = active.get(
"control")
13616 if all(path
and os.path.isfile(path)
for path
in (case_path, monitor_path, solver_control_path)):
13617 return case_path, monitor_path, solver_control_path
13618 all_yml_files = glob.glob(os.path.join(config_dir,
"*.yml"))
13619 case_path, monitor_path =
None,
None
13620 for f_path
in all_yml_files:
13623 if not isinstance(content, dict):
13625 if 'models' in content
and 'boundary_conditions' in content:
13627 elif 'io' in content
and 'logging' in content:
13628 monitor_path = f_path
13629 except Exception
as e:
13630 print(f
"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
13632 solver_control_path = glob.glob(os.path.join(config_dir,
"*.control"))[0]
13634 solver_control_path =
None
13635 return case_path, monitor_path, solver_control_path
13639 @brief Resolve post source directory token and optionally enforce existence.
13640 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
13641 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
13642 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
13643 @param[in] strict Argument passed to `resolve_post_source_directory()`.
13644 @return Value returned by `resolve_post_source_directory()`.
13646 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"])
13648 if source_dir_template ==
'<solver_output_dir>':
13649 resolved_source_dir = solver_output_dir_abs
13650 print(f
"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
13652 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
13653 print(f
"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
13655 if strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
13657 f
"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
13661 if not strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
13662 print(
"[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
13663 return resolved_source_dir
13670 case_index_tsv: str,
13678 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
13679 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
13680 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
13681 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
13682 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
13683 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
13684 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
13685 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
13686 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
13687 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
13688 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
13689 @return Value returned by `render_slurm_array_stage_script()`.
13691 effective_cluster_cfg = cluster_cfg
13692 resources = effective_cluster_cfg.get(
"resources", {})
13693 notifications = effective_cluster_cfg.get(
"notifications", {})
or {}
13694 execution = effective_cluster_cfg.get(
"execution", {})
or {}
13695 module_setup = execution.get(
"module_setup", [])
or []
13696 extra_sbatch = execution.get(
"extra_sbatch")
13700 f
"#SBATCH --job-name={job_name}",
13701 f
"#SBATCH --nodes={resources['nodes']}",
13702 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
13703 f
"#SBATCH --mem={resources['mem']}",
13704 f
"#SBATCH --time={resources['time']}",
13705 f
"#SBATCH --output={stdout_path}",
13706 f
"#SBATCH --error={stderr_path}",
13707 f
"#SBATCH --account={resources['account']}",
13708 f
"#SBATCH --array={array_spec}",
13710 partition = resources.get(
"partition")
13712 lines.append(f
"#SBATCH --partition={partition}")
13713 mail_user = notifications.get(
"mail_user")
13714 mail_type = notifications.get(
"mail_type")
13716 lines.append(f
"#SBATCH --mail-user={mail_user}")
13718 lines.append(f
"#SBATCH --mail-type={mail_type}")
13719 if isinstance(extra_sbatch, dict):
13720 for key, value
in extra_sbatch.items():
13722 if not flag.startswith(
"--"):
13724 if isinstance(value, bool):
13726 lines.append(f
"#SBATCH {flag}")
13727 elif value
is not None:
13728 lines.append(f
"#SBATCH {flag}={value}")
13729 elif isinstance(extra_sbatch, list):
13730 for token
in extra_sbatch:
13731 lines.append(f
"#SBATCH {token}")
13735 "set -euo pipefail",
13737 f
'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
13738 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
13739 'if [ -z "$LINE" ]; then',
13740 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
13743 "IFS=$'\\t' read -r CASE_INDEX CASE_ID RUN_DIR CONTROL_FILE POST_RECIPE_FILE LOG_LEVEL POST_PREFIX SOLVE_DIAGNOSTIC_ARGS POST_DIAGNOSTIC_ARGS <<< \"$LINE\"",
13745 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
13748 if stage ==
"solve":
13750 for key, value
in walltime_guard_exports.items():
13751 lines.append(f
"export {key}={value}")
13753 lines.append(
'export LOG_LEVEL="${LOG_LEVEL}"')
13755 for setup_line
in module_setup:
13756 lines.append(str(setup_line))
13758 if stage ==
"solve":
13760 effective_cluster_cfg,
13762 [
"-control_file",
"$CONTROL_FILE"]
13766 effective_cluster_cfg,
13768 [
"-control_file",
"$CONTROL_FILE",
"-postprocessing_config_file",
"$POST_RECIPE_FILE"],
13773 def _token(tok: str) -> str:
13775 @brief Preserve shell-variable tokens while safely quoting literal command arguments for an sbatch script.
13776 @param[in] tok Argument passed to `_token()`.
13777 @return Value returned by `_token()`.
13779 if tok.startswith(
"$"):
13781 return shlex.quote(str(tok))
13783 diag_var =
"${SOLVE_DIAGNOSTIC_ARGS}" if stage ==
"solve" else "${POST_DIAGNOSTIC_ARGS}"
13784 command_text =
" ".join(_token(t)
for t
in cmd)
13785 executable_token = _token(solver_exe
if stage ==
"solve" else post_exe)
13788 if executable_token
and command_text.count(executable_token) == 1:
13789 command_text = command_text.replace(f
"{executable_token} ", f
"{executable_token} {diag_var} ", 1)
13790 lines.append(f
"exec {command_text}")
13792 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
13793 with open(script_path,
"w")
as f:
13794 f.write(
"\n".join(lines) +
"\n")
13795 os.chmod(script_path, 0o755)
13806 @brief Generate a single-node sbatch script that runs metrics aggregation.
13807 @param[in] script_path Path to write the sbatch script.
13808 @param[in] job_name Slurm job name.
13809 @param[in] cluster_cfg Parsed cluster YAML dictionary.
13810 @param[in] study_dir Absolute path to the study directory.
13811 @param[in] picurv_path Absolute path to the picurv script.
13813 resources = cluster_cfg.get(
"resources", {})
13814 notifications = cluster_cfg.get(
"notifications", {})
or {}
13815 execution = cluster_cfg.get(
"execution", {})
or {}
13816 module_setup = execution.get(
"module_setup", [])
or []
13818 scheduler_dir = os.path.join(study_dir,
"scheduler")
13821 f
"#SBATCH --job-name={job_name}",
13822 "#SBATCH --nodes=1",
13823 "#SBATCH --ntasks-per-node=1",
13824 "#SBATCH --mem=4G",
13825 "#SBATCH --time=00:10:00",
13826 f
"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
13827 f
"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
13828 f
"#SBATCH --account={resources['account']}",
13830 partition = resources.get(
"partition")
13832 lines.append(f
"#SBATCH --partition={partition}")
13833 mail_user = notifications.get(
"mail_user")
13834 mail_type = notifications.get(
"mail_type")
13836 lines.append(f
"#SBATCH --mail-user={mail_user}")
13838 lines.append(f
"#SBATCH --mail-type={mail_type}")
13842 "set -euo pipefail",
13843 'echo "[$(date)] Running metrics aggregation"',
13846 for setup_line
in module_setup:
13847 lines.append(str(setup_line))
13850 f
"exec {shlex.quote(picurv_path)} sweep --reaggregate"
13851 f
" --study-dir {shlex.quote(study_dir)}"
13854 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
13855 with open(script_path,
"w")
as f:
13856 f.write(
"\n".join(lines) +
"\n")
13857 os.chmod(script_path, 0o755)
13862 @brief Reduce a metric series to one scalar according to the requested reducer.
13863 @param[in] values Sequence of numeric values.
13864 @param[in] reduction Reduction keyword.
13865 @return Value returned by `reduce_metric_values()`.
13870 reduction = str(reduction).lower()
13871 if reduction ==
"mean":
13872 return float(np.mean(values))
13873 if reduction ==
"min":
13874 return float(np.min(values))
13875 if reduction ==
"max":
13876 return float(np.max(values))
13877 if reduction ==
"p95":
13878 return float(np.percentile(values, 95.0))
13879 return float(values[-1])
13884 @brief Extract a scalar metric from a CSV source.
13885 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
13886 @param[in] spec Argument passed to `extract_metric_from_csv()`.
13887 @return Value returned by `extract_metric_from_csv()`.
13889 file_glob = spec.get(
"file_glob",
"**/*_msd.csv")
13890 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
13893 csv_path = candidates[0]
13895 with open(csv_path,
"r", newline=
"")
as f:
13896 reader = csv.DictReader(f)
13897 if reader.fieldnames:
13902 column = spec.get(
"column")
13903 numerator_column = spec.get(
"numerator_column")
13904 denominator_column = spec.get(
"denominator_column")
13905 denominator_floor = float(spec.get(
"denominator_floor", 0.0)
or 0.0)
13906 if not column
and not numerator_column:
13907 for name
in reversed(reader.fieldnames):
13908 if name
and name.lower()
not in {
"step",
"time",
"timestep"}:
13911 if not column
and not numerator_column:
13916 if numerator_column:
13917 numerator = float(row[numerator_column])
13918 denominator = float(row[denominator_column])
13919 denominator = max(denominator_floor, denominator)
13920 if denominator == 0.0:
13922 values.append(numerator / denominator)
13924 values.append(float(row[column]))
13934 @brief Extract a scalar metric from a log file using regex.
13935 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
13936 @param[in] spec Argument passed to `extract_metric_from_log()`.
13937 @return Value returned by `extract_metric_from_log()`.
13939 file_glob = spec.get(
"file_glob",
"logs/*.log")
13940 regex = spec.get(
"regex")
13943 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
13946 pattern = re.compile(regex)
13948 for path
in candidates:
13950 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13952 m = pattern.search(line)
13955 values.append(float(m.group(1)))
13965 @brief Normalize study metric definitions to a common dictionary form.
13966 @param[in] metric Argument passed to `normalize_metric_spec()`.
13967 @return Value returned by `normalize_metric_spec()`.
13969 if isinstance(metric, str):
13970 if metric.lower()
in {
"msd",
"msd_final"}:
13972 "name":
"msd_final",
13973 "source":
"statistics_csv",
13974 "file_glob":
"**/*_msd.csv",
13975 "reduction":
"last",
13977 return {
"name": metric,
"source":
"log_regex",
"regex": metric}
13978 return dict(metric)
13982 @brief Read the metrics table an earlier aggregation wrote, keyed by case id.
13983 @param[in] results_dir Study analysis directory holding metrics_table.csv.
13984 @return Mapping of case id to its previously recorded row, empty when absent.
13986 path = os.path.join(results_dir,
"metrics_table.csv")
13987 if not os.path.isfile(path):
13990 with open(path,
"r", encoding=
"utf-8", newline=
"")
as stream:
13992 row[
"case_id"]: row
for row
in csv.DictReader(stream)
if row.get(
"case_id")
13994 except (OSError, ValueError):
14000 @brief Collect metric values from generated case directories into one CSV.
14001 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
14002 @param[in] cases Argument passed to `aggregate_study_metrics()`.
14003 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
14004 @return Value returned by `aggregate_study_metrics()`.
14006 metrics = study_cfg.get(
"metrics", [])
14008 metrics = [
"msd_final"]
14017 row = {
"case_id": case[
"case_id"]}
14019 for p_key, p_val
in flat_parameters.items():
14021 if is_artifact_cold(case[
"run_dir"]):
14022 retained = preserved.get(case[
"case_id"])
14024 for spec
in normalized_specs:
14025 name = spec.get(
"name",
"metric")
14026 row[name] = retained.get(name)
14027 row[
"_source"] =
"retained"
14031 f
"[WARN] {case['case_id']} is in cold storage and no previous metrics "
14032 "row was found; its values are reported as unavailable.",
14035 for spec
in normalized_specs:
14036 row[spec.get(
"name",
"metric")] =
None
14039 for spec
in normalized_specs:
14040 name = spec.get(
"name",
"metric")
14041 source = str(spec.get(
"source",
"")).lower()
14042 if source
in METRIC_SOURCE_KINDS[:2]:
14044 elif source
in METRIC_SOURCE_KINDS[2:]:
14049 normalize_key = spec.get(
"normalize_by_parameter")
14050 if value
is not None and normalize_key:
14051 denom = flat_parameters.get(normalize_key)
14053 denom = float(denom)
14056 if denom
not in (
None, 0.0):
14057 value = float(value) / denom
14070 for k
in row.keys():
14075 os.makedirs(results_dir, exist_ok=
True)
14076 out_csv = os.path.join(results_dir,
"metrics_table.csv")
14077 with open(out_csv,
"w", newline=
"")
as f:
14078 writer = csv.DictWriter(f, fieldnames=all_keys)
14079 writer.writeheader()
14080 writer.writerows(rows)
14081 print(f
"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
14084_STUDY_REPORT_COLORS = (
14085 "#0072B2",
"#D55E00",
"#009E73",
"#CC79A7",
14086 "#E69F00",
"#56B4E9",
"#332288",
"#999999",
14092 @brief Return a concise report label for a study parameter path.
14093 @param[in] key Dotted study parameter path.
14094 @return Report-facing axis or legend label.
14097 "case.run_control.dt_physical":
"Physical timestep, Δt",
14098 "case.models.physics.particles.count":
"Particle count",
14099 "solver.operation_mode.uniform_flow.u":
"Uniform-flow velocity, u",
14100 "case.grid.programmatic_settings.im":
"Grid nodes in i, Nᵢ",
14101 "case.grid.programmatic_settings.jm":
"Grid nodes in j, Nⱼ",
14102 "case.grid.programmatic_settings.km":
"Grid nodes in k, Nₖ",
14109 @brief Resolve an optional configured metric label or humanize its name.
14110 @param[in] study_cfg Parsed study configuration.
14111 @param[in] metric Metric column name.
14112 @return Report-facing metric label.
14114 for raw_spec
in study_cfg.get(
"metrics", [])
or []:
14116 if spec.get(
"name") != metric:
14118 label = spec.get(
"plot_label")
or spec.get(
"label")
14119 units = spec.get(
"units")
14121 return f
"{base} ({units})" if units
else base
14127 @brief Parse one complete, finite numeric study-table column.
14128 @param[in] rows Metrics-table rows.
14129 @param[in] key Column name.
14130 @return Numeric values, or None when the column is incomplete or nonnumeric.
14135 value = float(row[key])
14136 except (KeyError, TypeError, ValueError):
14138 if not math.isfinite(value):
14140 values.append(value)
14146 @brief Infer the scientifically meaningful independent variable of a study.
14147 @param[in] study_cfg Parsed study configuration.
14148 @param[in] rows Metrics-table rows.
14149 @return Axis metadata, or None when no numeric independent variable exists.
14152 if not params
or not rows:
14156 "case.grid.programmatic_settings.im",
14157 "case.grid.programmatic_settings.jm",
14158 "case.grid.programmatic_settings.km",
14160 if study_cfg.get(
"study_type") ==
"grid_independence" and all(key
in params
for key
in grid_keys):
14162 if all(column
is not None for column
in columns):
14164 (columns[0][index] * columns[1][index] * columns[2][index]) ** (1.0 / 3.0)
14165 for index
in range(len(rows))
14168 "key":
"characteristic_grid_resolution",
14169 "label":
"Characteristic grid resolution, (NᵢNⱼNₖ)¹⁄³",
14170 "slug":
"characteristic_grid_resolution",
14172 "contributors": set(grid_keys),
14176 if study_cfg.get(
"study_type") ==
"timestep_independence":
14177 preferred = [key
for key
in params
if key.endswith(
".dt_physical")]
14179 for key
in preferred + [key
for key
in params
if key
not in preferred]:
14181 if values
is not None:
14182 numeric.append((key, values, len(set(values))))
14185 varied = [candidate
for candidate
in numeric
if candidate[2] > 1]
14186 key, values, _unique = (varied
or numeric)[0]
14190 "slug": re.sub(
r"[^A-Za-z0-9_.-]+",
"_", key),
14192 "contributors": {key},
14198 @brief Infer x-axis key/values for study plots.
14199 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
14200 @param[in] rows Argument passed to `infer_plot_x_axis()`.
14201 @return Value returned by `infer_plot_x_axis()`.
14206 return axis[
"label"], axis[
"values"]
14211 @brief Format a secondary study parameter compactly for a legend.
14212 @param[in] value Parameter value.
14213 @return Compact report string.
14216 number = float(value)
14217 except (TypeError, ValueError):
14219 if number.is_integer():
14220 return f
"{int(number):,}"
14221 return f
"{number:g}"
14226 @brief Group metric points by any secondary varied study parameters.
14227 @param[in] study_cfg Parsed study configuration.
14228 @param[in] rows Metrics-table rows.
14229 @param[in] axis Inferred independent-axis metadata.
14230 @param[in] metric Metric column name.
14231 @return Labeled point groups.
14235 for key
in param_keys:
14236 if key
in axis[
"contributors"]:
14238 values = {str(row.get(key))
for row
in rows}
14243 if 1 < len(values) < len(rows):
14244 secondary.append(key)
14247 for row_index, row
in enumerate(rows):
14249 y_value = float(row[metric])
14250 except (KeyError, TypeError, ValueError):
14252 if not math.isfinite(y_value):
14254 identity = tuple(row.get(key)
for key
in secondary)
14255 groups.setdefault(identity, []).append([axis[
"values"][row_index], y_value])
14258 for identity, points
in groups.items():
14259 points.sort(key=
lambda point: point[0])
14261 f
"{_study_parameter_label(key)} = {_format_study_group_value(value)}"
14262 for key, value
in zip(secondary, identity)
14264 result.append({
"label": label
or "Study cases",
"points": points})
14270 @brief Use log scaling only for positive data spanning a meaningful range.
14271 @param[in] values Candidate axis values.
14272 @param[in] semantic_hint Whether the quantity is conventionally read logarithmically.
14273 @return True when logarithmic scaling improves interpretation.
14275 if not values
or any(value <= 0.0
for value
in values):
14277 ratio = max(values) / min(values)
14278 return ratio >= (20.0
if semantic_hint
else 100.0)
14283 @brief Build padded linear limits that include zero for non-negative metrics.
14284 @param[in] values Finite metric values.
14285 @return Lower and upper limits, or None for an empty sequence.
14289 lower, upper = min(values), max(values)
14292 span = upper - lower
14293 padding = max(span * 0.06, abs(upper) * 0.02, 1.0e-12)
14294 return (lower, upper + padding)
if lower == 0.0
else (lower - padding, upper + padding)
14298 @brief Generate metric-vs-parameter plots for completed studies.
14299 @param[in] study_cfg Argument passed to `generate_study_plots()`.
14300 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
14301 @param[in] plots_dir Argument passed to `generate_study_plots()`.
14302 @return Value returned by `generate_study_plots()`.
14304 plotting_cfg = study_cfg.get(
"plotting", {})
or {}
14305 if plotting_cfg.get(
"enabled",
True)
is False:
14306 print(
"[INFO] Plotting disabled by study.yml.")
14310 print(
"[WARNING] matplotlib not available; skipping plot generation.")
14312 if not metrics_csv
or not os.path.isfile(metrics_csv):
14315 with open(metrics_csv,
"r", newline=
"")
as f:
14316 reader = csv.DictReader(f)
14317 rows =
list(reader)
14323 print(
"[WARNING] Could not infer numeric x-axis for plots; skipping.")
14326 configured_metrics = study_cfg.get(
"metrics", [])
or [
"msd_final"]
14332 out_format = plotting_cfg.get(
"output_format",
"png")
14333 os.makedirs(plots_dir, exist_ok=
True)
14335 for metric
in metric_keys:
14339 all_x = [point[0]
for group
in groups
for point
in group[
"points"]]
14340 all_y = [point[1]
for group
in groups
for point
in group[
"points"]]
14342 metric_hint = metric.lower()
14345 any(token
in metric_hint
for token
in (
"error",
"residual",
"drift",
"msd")),
14348 grid_cross_product = (
14349 study_cfg.get(
"study_type") ==
"grid_independence"
14350 and bool(study_cfg.get(
"parameters"))
and len(axis[
"contributors"]) == 3
14352 len(set(float(row[key])
for row
in rows)) > 1
14353 for key
in axis[
"contributors"]
14357 plt.figure(figsize=(9.2, 5.5), facecolor=
"white")
14358 for index, group
in enumerate(groups):
14359 x_values = [point[0]
for point
in group[
"points"]]
14360 y_values = [point[1]
for point
in group[
"points"]]
14361 repeated_x = len(set(x_values)) != len(x_values)
14363 x_values, y_values,
14364 color=_STUDY_REPORT_COLORS[index % len(_STUDY_REPORT_COLORS)],
14365 marker=
"o", markersize=5.2, markeredgewidth=0.7,
14366 linewidth=0.0
if repeated_x
or grid_cross_product
else 1.8,
14367 linestyle=
"none" if repeated_x
or grid_cross_product
else "-",
14368 label=group[
"label"],
14370 plt.xlabel(axis[
"label"], fontsize=11)
14371 plt.ylabel(metric_label, fontsize=11)
14373 "grid_independence":
"Grid-independence study",
14374 "timestep_independence":
"Timestep-independence study",
14375 "sensitivity":
"Sensitivity study",
14376 }.get(study_cfg.get(
"study_type"),
"Parameter study")
14377 plt.title(f
"{metric_label} vs. {axis['label']}\n{study_label}", fontsize=13, loc=
"left", pad=12)
14378 if x_log
and hasattr(plt,
"xscale"):
14382 elif hasattr(plt,
"ylim"):
14386 if hasattr(plt,
"tick_params"):
14387 plt.tick_params(axis=
"both", which=
"major", labelsize=9.5)
14388 plt.grid(
True, alpha=0.24, which=
"major", linewidth=0.7)
14390 plt.grid(
True, alpha=0.08, which=
"minor", linewidth=0.5)
14391 if len(groups) > 1:
14393 loc=
"upper left", bbox_to_anchor=(1.01, 1.0), borderaxespad=0.0,
14394 frameon=
False, title=
"Fixed parameters", fontsize=9, title_fontsize=9.5,
14396 plt.tight_layout(rect=(0.0, 0.0, 0.76, 1.0))
14399 safe_metric = re.sub(
r"[^A-Za-z0-9_.-]+",
"_", metric)
14400 out_path = os.path.join(plots_dir, f
"{safe_metric}_vs_{axis['slug']}.{out_format}")
14401 plt.savefig(out_path, dpi=240, bbox_inches=
"tight", facecolor=
"white")
14403 generated.append(out_path)
14405 print(f
"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
14411 @brief Render a command list as a shell-safe display string.
14412 @param[in] command_tokens Argument passed to `_command_to_string()`.
14413 @return Value returned by `_command_to_string()`.
14415 return " ".join(shlex.quote(str(tok))
for tok
in command_tokens)
14420 @brief Resolve post source directory without side effects or stdout/stderr output.
14421 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
14422 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
14423 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
14424 @return Value returned by `_resolve_post_source_directory_preview()`.
14426 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"])
14428 if source_dir_template ==
'<solver_output_dir>':
14429 return solver_output_dir_abs
14430 return os.path.abspath(os.path.join(run_dir, source_dir_template))
14435 @brief Build a no-write execution plan for `run --dry-run`.
14436 @param[in] args Command-line style argument list supplied to the function.
14437 @return Value returned by `build_run_dry_plan()`.
14441 "created_at": datetime.now().isoformat(),
14448 if args.dry_run
and args.no_submit:
14449 plan[
"warnings"].append(
"--dry-run takes precedence over --no-submit; no files will be written.")
14451 cluster_mode = bool(getattr(args,
"cluster",
None))
14453 cluster_path =
None
14454 solver_num_procs_effective = args.num_procs
14455 post_num_procs_effective = args.num_procs
14458 solver_control_path =
None
14459 loaded_case_cfg =
None
14460 loaded_monitor_cfg =
None
14461 resolved_restart_source_dir =
None
14464 cluster_path = os.path.abspath(args.cluster)
14467 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
14468 if args.scheduler
and args.scheduler.lower() != scheduler_type:
14470 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14471 key=
"scheduler.type",
14472 file_path=cluster_path,
14473 message=f
"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
14476 if scheduler_type !=
"slurm":
14478 ERROR_CODE_CFG_INVALID_VALUE,
14479 key=
"scheduler.type",
14480 file_path=cluster_path,
14481 message=f
"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.",
14485 if (args.solve
or args.post_process)
and args.num_procs
not in (1, cluster_tasks):
14487 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14488 key=
"resources.ntasks_per_node",
14489 file_path=cluster_path,
14491 "--num-procs must be 1 (auto) or "
14492 f
"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
14497 solver_num_procs_effective = cluster_tasks
14498 if args.post_process:
14499 post_num_procs_effective = cluster_tasks
14500 plan[
"launch_mode"] =
"slurm"
14501 plan[
"inputs"][
"cluster"] = cluster_path
14503 if getattr(args,
"scheduler",
None):
14504 fail_cli_usage(
"--scheduler requires --cluster in this version.")
14505 plan[
"launch_mode"] =
"local"
14509 if getattr(args,
'restart_from',
None):
14510 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
14511 if getattr(args,
'continue_run',
False)
and not args.post_process:
14512 print(
"[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
14515 case_path = os.path.abspath(args.case)
14520 solver_path = os.path.abspath(args.solver)
14521 monitor_path = os.path.abspath(args.monitor)
14527 continue_mode = getattr(args,
'continue_run',
False)
14531 if not args.run_dir:
14533 run_dir = os.path.abspath(args.run_dir)
14534 if not os.path.isdir(run_dir):
14536 ERROR_CODE_CFG_FILE_NOT_FOUND,
14539 message=
"Specified run directory not found.",
14542 run_id = os.path.basename(run_dir)
14546 run_dir = os.path.join(runs_root, run_id)
14550 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir,
14553 plan[
"lineage"] = planned_lineage
or {
"relationship":
"root"}
14554 except ValueError
as e:
14556 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14558 file_path=case_path,
14563 config_dir = os.path.join(run_dir,
"config")
14564 scheduler_dir = os.path.join(run_dir,
"scheduler")
14569 logs_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"logs"])
14570 planned_output_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"])
14571 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
14572 profile_path = os.path.join(config_dir,
"profile.run")
14575 plan[
"run_id_preview"] = run_id
14576 plan[
"run_dir_preview"] = run_dir
14581 plan.setdefault(
"blocking", [])
14585 plan[
"blocking"].extend(structure_errors)
14586 plan[
"warnings"].extend(structure_warnings)
14589 if _authorized
and _verdict
in WAIVABLE_PHYSICAL_VERDICTS:
14590 plan[
"warnings"].append(f
"{_message} Allowed only because "
14591 f
"'allow_unsafe_paths: true' is set.")
14593 plan[
"blocking"].append(_message)
14594 plan[
"inputs"].update({
"case": case_path,
"solver": solver_path,
"monitor": monitor_path})
14596 plan[
"asset_actions"] = [
14597 {
"kind": item[
"kind"],
"provider": item[
"provider"],
"action": item[
"action"]}
14598 for item
in asset_plan[
"actions"]
14601 item[
"kind"]
for item
in asset_plan[
"actions"]
14602 if item[
"execution"] ==
"runtime-c"
14604 for item
in asset_plan[
"actions"]:
14605 blocked_dependencies = runtime_kinds.intersection(item.get(
"dependencies", []))
14606 if item[
"execution"] ==
"precomputable" and blocked_dependencies:
14607 plan[
"blocking"].append(
14608 f
"{item['kind']}={item['provider']} requires a file-backed "
14609 f
"{', '.join(sorted(blocked_dependencies))}, but that dependency is generated "
14610 "only inside the simulator. Select a precomputable provider for the dependency."
14612 plan[
"artifacts"].extend(
14615 *(os.path.join(run_dir, *relative.split(
"/"))
14616 for relative
in RUN_DIRECTORY_LAYOUT),
14617 os.path.join(config_dir,
"case.yml"),
14618 os.path.join(config_dir,
"solver.yml"),
14619 os.path.join(config_dir,
"monitor.yml"),
14620 os.path.join(config_dir,
"active.json"),
14621 os.path.join(run_dir, CANONICAL_RUN_PATHS[
"inputs"],
"assets.lock.yml"),
14622 solver_control_path,
14623 os.path.join(run_dir,
"manifest.json"),
14630 plan[
"artifacts"].append(os.path.join(config_dir,
"whitelist.run"))
14631 if profiling_preview[
"mode"] ==
"selected":
14632 plan[
"artifacts"].append(profile_path)
14634 plan[
"artifacts"].extend(solve_diagnostics[
"artifacts"])
14636 plan[
"artifacts"].append(os.path.join(config_dir,
"cluster.yml"))
14637 plan[
"artifacts"].append(os.path.join(scheduler_dir,
"submission.json"))
14642 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
14647 config_search_anchor=case_path,
14648 extra_search_anchors=[cluster_path],
14650 plan[
"artifacts"].append(solver_script)
14651 plan[
"stages"][
"solve"] = {
14653 "script": solver_script,
14654 "num_procs_effective": solver_num_procs_effective,
14655 "launch_command": solver_cmd,
14662 solver_num_procs_effective,
14663 config_search_anchor=case_path,
14665 solver_stream_log = os.path.join(scheduler_dir, f
"{run_id}_solver.log")
14666 plan[
"artifacts"].append(solver_stream_log)
14667 plan[
"stages"][
"solve"] = {
14669 "num_procs_effective": solver_num_procs_effective,
14670 "stream_log": solver_stream_log,
14671 "launch_command": solver_cmd,
14674 if resolved_restart_source_dir:
14675 plan[
"stages"][
"solve"][
"restart_source_directory"] = resolved_restart_source_dir
14677 plan[
"stages"][
"solve"][
"continue_mode"] =
True
14679 if args.post_process:
14680 post_path = os.path.abspath(args.post)
14681 plan[
"inputs"][
"post"] = post_path
14686 run_dir = os.path.abspath(args.run_dir)
14687 if not os.path.isdir(run_dir):
14689 ERROR_CODE_CFG_FILE_NOT_FOUND,
14692 message=
"Specified run directory not found.",
14695 run_id = os.path.basename(run_dir)
14696 elif not args.solve:
14697 fail_cli_usage(
"--post-process requires --run-dir when not used with --solve.")
14700 config_dir = os.path.join(run_dir,
"config")
14702 if not all([case_path, monitor_path, solver_control_path]):
14704 ERROR_CODE_CFG_MISSING_KEY,
14705 key=
"run_dir.config",
14706 file_path=config_dir,
14708 "Could not auto-identify required run inputs "
14709 "(case.yml/monitor.yml/*.control) in run config directory."
14716 config_dir = os.path.join(run_dir,
"config")
14717 case_path = os.path.join(config_dir,
"case.yml")
14718 monitor_path = os.path.join(config_dir,
"monitor.yml")
14719 if solver_control_path
is None:
14720 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
14723 allow_source_frontier_scan =
not args.solve
14728 loaded_monitor_cfg,
14730 continue_requested=getattr(args,
'continue_run',
False),
14731 allow_source_frontier_scan=allow_source_frontier_scan,
14735 output_dir_rel = post_cfg.get(
"io", {}).get(
"output_directory")
14736 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix")
14737 if not output_prefix:
14739 ERROR_CODE_CFG_MISSING_KEY,
14740 key=
"io.output_filename_prefix",
14741 file_path=post_path,
14742 message=
"Missing required post output filename prefix.",
14745 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
14749 plan[
"artifacts"].extend(post_diagnostics[
"artifacts"])
14752 solver_control_path,
14753 "-postprocessing_config_file",
14756 plan[
"artifacts"].extend([
14759 post_plan[
"resume_state_path"],
14760 post_plan[
"lock_paths"][
"wrapper_path"],
14761 post_plan[
"lock_paths"][
"lock_file"],
14762 post_plan[
"lock_paths"][
"metadata_file"],
14764 plan[
"artifacts"].extend(statistics_output_paths)
14767 "source_data_directory": post_plan[
"source_data_directory"],
14768 "requested_start_step": post_plan[
"requested_start_step"],
14769 "requested_end_step": post_plan[
"requested_end_step"],
14770 "step_interval": post_plan[
"step_interval"],
14771 "resume_applied": bool(post_plan[
"continue_requested"]
and post_plan[
"resume_recipe_match"]),
14772 "resume_recipe_match": post_plan[
"resume_recipe_match"],
14773 "resume_bootstrapped": post_plan[
"resume_bootstrapped"],
14774 "resume_match_source": post_plan[
"resume_match_source"],
14775 "completed_frontier_step": post_plan[
"completed_frontier_step"],
14776 "source_frontier_step": post_plan[
"source_frontier_step"],
14777 "source_frontier_diagnostic": post_plan[
"source_frontier_diagnostic"],
14778 "source_frontier_deferred": post_plan[
"source_frontier_deferred"],
14779 "effective_start_step": post_plan[
"effective_start_step"],
14780 "effective_end_step": post_plan[
"effective_end_step"],
14781 "skip_reason": post_plan[
"skip_reason"],
14782 "post_skipped_as_complete": post_plan[
"skip_reason"] ==
"already-complete-window",
14783 "recipe_fingerprint": post_plan[
"recipe_fingerprint"],
14784 "num_procs_effective": post_num_procs_effective,
14787 if post_plan[
"skip_reason"]
is None:
14789 scheduler_dir = os.path.join(run_dir,
"scheduler")
14790 post_script = os.path.join(scheduler_dir,
"post.sbatch")
14791 post_cluster_cfg = cluster_cfg
14796 config_search_anchor=case_path,
14797 extra_search_anchors=[cluster_path],
14798 force_num_procs=post_num_procs_effective,
14802 post_plan[
"recipe_fingerprint"],
14804 create_wrapper=
False,
14806 plan[
"artifacts"].append(post_script)
14807 stage_meta.update({
14809 "script": post_script,
14810 "launch_command": post_cmd,
14817 post_num_procs_effective,
14818 config_search_anchor=case_path,
14819 allow_single_rank_launcher_override=
True,
14820 force_num_procs=post_num_procs_effective,
14824 post_plan[
"recipe_fingerprint"],
14826 create_wrapper=
False,
14828 post_stream_log = os.path.join(run_dir,
"scheduler", f
"{run_id}_{output_prefix}.log")
14829 plan[
"artifacts"].append(post_stream_log)
14830 stage_meta.update({
14832 "stream_log": post_stream_log,
14833 "launch_command": post_cmd,
14837 stage_meta.update({
14838 "mode":
"slurm" if cluster_mode
else "local",
14839 "launch_command": [],
14840 "launch_command_string":
"",
14843 plan[
"stages"][
"post-process"] = stage_meta
14848 for item
in plan[
"artifacts"]:
14849 if item
not in seen:
14851 deduped.append(item)
14852 plan[
"artifacts"] = deduped
14853 if run_id
and "run_id_preview" not in plan:
14854 plan[
"run_id_preview"] = run_id
14855 if run_dir
and "run_dir_preview" not in plan:
14856 plan[
"run_dir_preview"] = run_dir
14857 plan[
"solver_num_procs_effective"] = solver_num_procs_effective
14858 plan[
"post_num_procs_effective"] = post_num_procs_effective
14859 plan[
"num_procs_effective"] = solver_num_procs_effective
14865 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
14866 @param[in,out] plan Dry-run plan to update.
14867 @param[in] case_cfg Parsed case configuration.
14868 @param[in] run_dir Preview run directory for relative artifact resolution.
14870 grid_cfg = case_cfg.get(
"grid", {})
14871 if not isinstance(grid_cfg, dict):
14874 mode = grid_cfg.get(
"mode")
14875 grid_dir = os.path.join(run_dir,
"inputs",
"grid")
14878 plan[
"artifacts"].append(os.path.join(grid_dir,
"grid.run"))
14879 elif mode ==
"grid_gen":
14880 generator = grid_cfg.get(
"generator", {})
14881 if not isinstance(generator, dict):
14883 plan[
"artifacts"].extend([
14884 os.path.join(grid_dir,
"grid.run"),
14885 os.path.join(grid_dir,
"grid.generated.picgrid"),
14886 os.path.join(run_dir,
"output",
"analysis",
"metrics",
"grid.info"),
14887 os.path.join(run_dir,
"output",
"visualization",
"precompute",
"grid.vts"),
14892 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
14893 @param[in,out] plan Dry-run plan to update.
14894 @param[in] case_cfg Parsed case configuration.
14895 @param[in] run_dir Preview run directory.
14901 profile_dir = os.path.join(run_dir,
"inputs",
"inlet_profiles")
14902 has_generated =
False
14903 for block_idx, block
in enumerate(prepared_blocks):
14905 if bc.get(
"handler") !=
"prescribed_flow":
14907 source = (bc.get(
"params")
or {}).get(
"source", {})
14908 if source.get(
"type")
not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
14910 has_generated =
True
14912 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
14913 generated_path = os.path.join(
14914 profile_dir, f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice"
14916 staged_path = os.path.join(profile_dir, f
"inlet_profile_block{block_idx}_{face_token}.picslice")
14917 plan[
"artifacts"].append(generated_path)
14918 plan[
"artifacts"].append(staged_path)
14920 plan[
"artifacts"].append(os.path.join(profile_dir,
"profile.info"))
14924 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
14925 @param[in,out] plan Dry-run plan receiving artifact paths.
14926 @param[in] case_cfg Parsed case configuration.
14927 @param[in] solver_cfg Parsed solver configuration.
14928 @param[in] run_dir Planned run directory.
14931 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
14933 start_step = int((case_cfg.get(
"run_control", {})
or {}).get(
"start_step", 0)
or 0)
14934 if source !=
"solve" or start_step != 0:
14939 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
14941 U_ref=fluid_scaling[
"velocity_ref"],
14942 provider_context={
"kinematic_viscosity": fluid_scaling[
"nondimensional_kinematic_viscosity"]},
14944 except (KeyError, ValueError):
14948 initial_dir = os.path.join(run_dir,
"inputs",
"initial_condition")
14949 plan[
"artifacts"].append(
14950 os.path.join(initial_dir, f
"{resolved['field_name']}00000_0.dat")
14953 if (case_cfg.get(
"grid", {})
or {}).get(
"mode") ==
"programmatic_c":
14954 plan[
"artifacts"].append(os.path.join(run_dir,
"inputs",
"grid",
"grid.run"))
14955 plan[
"artifacts"].append(os.path.join(initial_dir,
"initial_condition.generated.dat"))
14956 if resolved[
"kind"] ==
"spectral_random_velocity":
14957 plan[
"artifacts"].extend([
14958 os.path.join(run_dir,
"output",
"analysis",
"metrics",
"initial_condition_summary.json"),
14959 os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH),
14965 @brief Render dry-run plan in human or JSON format.
14966 @param[in] plan Dry-run plan produced by build_run_dry_plan().
14967 @param[in] output_format Either "text" or "json".
14968 @return 1 when the plan carries blocking findings, 0 otherwise.
14970 if output_format ==
"json":
14971 print(json.dumps(plan, indent=2, sort_keys=
True))
14972 return 1
if plan.get(
"blocking")
else 0
14974 for message
in plan.get(
"warnings", []):
14975 print(f
"[WARN] {message}", file=sys.stderr)
14976 if plan.get(
"blocking"):
14977 print(
"[FATAL] This configuration would be refused. The plan below is what the "
14978 "run WOULD do; it will not get that far:", file=sys.stderr)
14979 for message
in plan[
"blocking"]:
14980 print(f
" {message}", file=sys.stderr)
14982 print(
"\n" +
"=" * 60)
14983 print(
" DRY-RUN PLAN")
14985 print(f
" Launch mode : {plan.get('launch_mode')}")
14986 print(f
" Created at : {plan.get('created_at')}")
14987 if plan.get(
"run_id_preview"):
14988 print(f
" Run ID preview : {plan.get('run_id_preview')}")
14989 if plan.get(
"run_dir_preview"):
14990 print(f
" Run dir preview: {plan.get('run_dir_preview')}")
14991 lineage = plan.get(
"lineage")
or {}
14992 if lineage.get(
"relationship") ==
"branch":
14993 print(f
" Branched from : {lineage.get('parent_run_id')} "
14994 f
"@ step {lineage.get('checkpoint_step')} "
14995 f
"(statistics: {lineage.get('statistics_state')})")
14996 print(f
" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
14997 print(f
" Post MPI procs : {plan.get('post_num_procs_effective')}")
14998 if plan.get(
"warnings"):
14999 print(
" Warnings :")
15000 for warning
in plan[
"warnings"]:
15001 print(f
" - {warning}")
15003 if plan.get(
"inputs"):
15004 print(
"\n Inputs:")
15005 for key, value
in plan[
"inputs"].items():
15006 print(f
" - {key}: {value}")
15008 if plan.get(
"stages"):
15009 print(
"\n Planned stage commands:")
15010 for stage, details
in plan[
"stages"].items():
15011 print(f
" - {stage} ({details.get('mode')}):")
15012 if details.get(
'skip_reason'):
15013 print(f
" skipped: {details.get('skip_reason')}")
15015 print(f
" {details.get('launch_command_string')}")
15017 if plan.get(
"asset_actions"):
15018 print(
"\n Asset resolution:")
15019 for item
in plan[
"asset_actions"]:
15020 print(f
" - {item['kind']}: {item['action']} ({item['provider']})")
15022 diagnostics_artifacts = [item
for item
in plan.get(
"artifacts", [])
if "PETSc_" in os.path.basename(str(item))
or os.path.basename(str(item)) ==
"Runtime_Memory.log"]
15023 if diagnostics_artifacts:
15024 print(
"\n Diagnostics artifacts:")
15025 for artifact
in diagnostics_artifacts:
15026 print(f
" - {artifact}")
15028 print(
"\n Planned artifacts (no files created in dry-run):")
15029 for artifact
in plan.get(
"artifacts", []):
15030 print(f
" - {artifact}")
15036 @brief Implements `picurv validate` without launching solver/post workflows.
15037 @param[in] args Command-line style argument list supplied to the function.
15040 solver_group_selected = any([args.case, args.solver, args.monitor])
15041 any_group_selected = solver_group_selected
or any([args.post, args.cluster, args.study])
15043 cluster_path =
None
15045 if not any_group_selected:
15047 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
15048 hint=
"Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
15051 if solver_group_selected
and not all([args.case, args.solver, args.monitor]):
15052 fail_cli_usage(
"When solver validation is requested, --case, --solver, and --monitor are all required.")
15055 restart_from = getattr(args,
'restart_from',
None)
15056 continue_run = getattr(args,
'continue_run',
False)
15057 run_dir_val = getattr(args,
'run_dir',
None)
15058 if not solver_group_selected:
15060 print(
"[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
15061 if continue_run
and not args.post:
15062 print(
"[WARNING] --continue has no effect without solver configs or --post and will be ignored.", file=sys.stderr)
15063 if continue_run
and not run_dir_val:
15068 if solver_group_selected:
15069 case_path = os.path.abspath(args.case)
15070 solver_path = os.path.abspath(args.solver)
15071 monitor_path = os.path.abspath(args.monitor)
15076 checked.extend([case_path, solver_path, monitor_path])
15079 if restart_from
or continue_run:
15080 target_run_dir = os.path.abspath(run_dir_val)
if run_dir_val
else os.path.abspath(
"runs/_validate_dummy")
15083 print(
"[SUCCESS] Restart source validation passed.")
15084 except ValueError
as e:
15085 print(f
"[ERROR] Restart validation failed: {e}", file=sys.stderr)
15090 post_path = os.path.abspath(args.post)
15093 checked.append(post_path)
15097 cluster_path = os.path.abspath(args.cluster)
15100 checked.append(cluster_path)
15105 extra_search_anchors=[cluster_path]
if cluster_path
else None,
15107 except ValueError
as exc:
15109 ERROR_CODE_CFG_INVALID_VALUE,
15110 key=
"runtime_execution",
15111 file_path=case_path
or cluster_path
or os.getcwd(),
15115 if runtime_execution_path:
15116 checked.append(runtime_execution_path)
15120 study_path = os.path.abspath(args.study)
15123 checked.append(study_path)
15125 if post_cfg
is not None and run_dir_val:
15126 post_path = os.path.abspath(args.post)
15127 validate_run_dir = os.path.abspath(run_dir_val)
15128 if os.path.isdir(validate_run_dir):
15129 monitor_for_post = monitor_cfg
if solver_group_selected
else None
15130 if monitor_for_post
is None:
15131 config_dir_candidate = os.path.join(validate_run_dir,
"config")
15132 monitor_candidate = os.path.join(config_dir_candidate,
"monitor.yml")
15133 if os.path.isfile(monitor_candidate):
15135 if monitor_for_post
is not None:
15137 if os.path.isdir(resolved_source)
and os.listdir(resolved_source):
15138 print(f
"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
15140 print(f
"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
15142 if args.strict
and post_cfg
is not None:
15143 post_path = os.path.abspath(args.post)
15144 source_dir = post_cfg.get(
"source_data", {}).get(
"directory")
15145 if source_dir
and source_dir !=
"<solver_output_dir>":
15147 if not os.path.isdir(resolved):
15149 ERROR_CODE_CFG_FILE_NOT_FOUND,
15150 key=
"source_data.directory",
15151 file_path=post_path,
15152 message=f
"strict mode: source_data.directory resolves to missing directory '{resolved}'.",
15156 if args.strict
and study_cfg
is not None:
15157 study_path = os.path.abspath(args.study)
15158 base_cfgs = study_cfg.get(
"base_configs", {})
15159 if isinstance(base_cfgs, dict):
15160 base_case_path =
resolve_path(study_path, base_cfgs.get(
"case"))
15161 base_solver_path =
resolve_path(study_path, base_cfgs.get(
"solver"))
15162 base_monitor_path =
resolve_path(study_path, base_cfgs.get(
"monitor"))
15163 base_post_path =
resolve_path(study_path, base_cfgs.get(
"post"))
15164 if all([base_case_path, base_solver_path, base_monitor_path]):
15175 read_yaml_file(base_monitor_path)
if base_monitor_path
else None,
15178 print(f
"[SUCCESS] Validation completed for {len(checked)} file(s).")
15179 for path
in checked:
15180 print(f
" - {path}")
15182ASSET_KIND_DIRECTORIES = {
15184 "initial-condition":
"initial_conditions",
15185 "inlet-profiles":
"inlet_profiles",
15191 @brief Hash an asset source or payload without loading it into memory.
15192 @param[in] path File to hash.
15193 @return Lowercase SHA-256 digest.
15195 digest = hashlib.sha256()
15196 with open(path,
"rb")
as stream:
15197 for block
in iter(
lambda: stream.read(8 * 1024 * 1024), b
""):
15198 digest.update(block)
15199 return digest.hexdigest()
15204 @brief Hash a JSON-compatible value with deterministic serialization.
15205 @param[in] payload Value to hash.
15206 @return Lowercase SHA-256 digest.
15208 encoded = json.dumps(payload, sort_keys=
True, separators=(
",",
":"), default=str).encode(
"utf-8")
15209 return hashlib.sha256(encoded).hexdigest()
15214 @brief Hash every existing file explicitly referenced by an asset provider.
15215 @param[in] value Provider subtree to inspect.
15216 @param[in] case_path Owning case configuration path.
15217 @param[in] key Current dotted key for diagnostics.
15218 @return Mapping of dotted path identity to path/content fingerprint.
15221 if isinstance(value, dict):
15222 for child_key, child
in value.items():
15223 dotted = f
"{key}.{child_key}" if key
else str(child_key)
15226 if isinstance(value, list):
15227 for index, child
in enumerate(value):
15230 basename = key.rsplit(
".", 1)[-1]
15231 if basename
not in _ASSET_SOURCE_REFERENCE_KEYS
or not isinstance(value, str)
or not value.strip():
15237 if os.path.isfile(resolved):
15240 os.path.relpath(resolved, workspace_root).replace(os.sep,
"/")
15241 if workspace_root
and os.path.commonpath([workspace_root, resolved]) == workspace_root
15250 @brief Classify case inputs into precomputable or simulator-runtime providers.
15251 @param[in] case_cfg Parsed case configuration.
15252 @param[in] case_path Owning case YAML path.
15253 @return Provider graph with stable identities and explicit dependencies.
15256 grid_cfg = copy.deepcopy(case_cfg.get(
"grid", {})
or {})
15257 grid_mode = str(grid_cfg.get(
"mode",
"")).strip().lower()
15258 if isinstance(grid_cfg.get(
"generator"), dict):
15259 grid_cfg[
"generator"].pop(
"output_file",
None)
15260 grid_cfg[
"generator"].pop(
"vts_file",
None)
15261 grid_cfg[
"generator"].pop(
"stats_file",
None)
15264 "provider": grid_mode
or "missing",
15265 "execution":
"runtime-c" if grid_mode ==
"programmatic_c" else "precomputable",
15266 "dependencies": [],
15269 providers.append(grid_provider)
15271 initial = copy.deepcopy(
15272 ((case_cfg.get(
"properties")
or {}).get(
"initial_conditions")
or {})
15274 initial_mode = str(initial.get(
"mode",
"generated")).strip().lower()
15275 initial_generator = str(initial.get(
"generator",
"constant")).strip().lower()
15276 for key
in (
"output_file",
"summary_json",
"spectrum_csv"):
15277 initial.pop(key,
None)
15278 if isinstance(initial.get(
"params"), dict):
15279 initial[
"params"].pop(key,
None)
15280 generated_python = (
15281 initial_mode ==
"generated"
15282 and initial_generator
in _PYTHON_INITIAL_CONDITION_PROVIDERS
15284 initial_provider = {
15285 "kind":
"initial-condition",
15286 "provider":
"file" if initial_mode ==
"file" else initial_generator,
15287 "execution":
"precomputable" if initial_mode ==
"file" or generated_python
else "runtime-c",
15288 "dependencies": [
"grid"]
if generated_python
else [],
15291 providers.append(initial_provider)
15294 raw_blocks = case_cfg.get(
"boundary_conditions")
or []
15295 if raw_blocks
and isinstance(raw_blocks[0], dict):
15296 raw_blocks = [raw_blocks]
15297 for block_index, block
in enumerate(raw_blocks):
15298 for entry
in block
or []:
15299 if not isinstance(entry, dict)
or str(entry.get(
"handler",
"")).strip().lower() !=
"prescribed_flow":
15301 source = copy.deepcopy(((entry.get(
"params")
or {}).get(
"source")
or {}))
15302 source.pop(
"output_file",
None)
15303 inlet_specs.append({
"block": block_index,
"face": entry.get(
"face"),
"source": source})
15305 field_slice = any((item.get(
"source")
or {}).get(
"type") ==
"field_slice" for item
in inlet_specs)
15307 "kind":
"inlet-profiles",
15308 "provider":
"prescribed-flow",
15309 "execution":
"precomputable",
15310 "dependencies": [
"grid"]
if field_slice
or grid_mode
in _FILE_BACKED_GRID_VALUES
else [],
15311 "spec": inlet_specs,
15320 scaling = (case_cfg.get(
"properties")
or {}).get(
"scaling")
or {}
15321 fluid = (case_cfg.get(
"properties")
or {}).get(
"fluid")
or {}
15322 domain_blocks = (case_cfg.get(
"models")
or {}).get(
"domain", {}).get(
"blocks", 1)
15325 "length_ref": scaling.get(
"length_ref"),
15326 "blocks": domain_blocks,
15329 "initial-condition": {
15330 "length_ref": scaling.get(
"length_ref"),
15331 "velocity_ref": scaling.get(
"velocity_ref"),
15332 "density": fluid.get(
"density"),
15333 "viscosity": fluid.get(
"viscosity"),
15334 "boundary_conditions": case_cfg.get(
"boundary_conditions"),
15337 "inlet-profiles": {
15338 "length_ref": scaling.get(
"length_ref"),
15339 "velocity_ref": scaling.get(
"velocity_ref"),
15340 "blocks": domain_blocks,
15346 by_kind = {provider[
"kind"]: provider
for provider
in providers}
15347 resolved_order = sorted(
15348 providers, key=
lambda provider: len(provider.get(
"dependencies")
or [])
15350 for provider
in resolved_order:
15351 provider[
"build_context"] = build_contexts.get(provider[
"kind"], {})
15353 provider[
"software"] = {
15354 "release_version": PICURV_RELEASE_VERSION,
15355 "git_commit": PICURV_BUILD.get(
"git_commit"),
15358 "kind": provider[
"kind"],
15359 "provider": provider[
"provider"],
15360 "spec": provider[
"spec"],
15361 "build_context": provider[
"build_context"],
15362 "source_files": provider[
"source_files"],
15363 "software": provider[
"software"],
15365 name: by_kind[name].get(
"spec_sha256")
15366 for name
in (provider.get(
"dependencies")
or [])
15372 "providers": providers,
15378 @brief Resolve requested asset kinds plus dependency closure.
15379 @param[in] graph Provider graph returned by build_case_asset_graph.
15380 @param[in] requested Optional iterable of requested kind names.
15381 @param[in] precomputable_only Exclude runtime-C providers for normal-run staging.
15382 @return Ordered provider list.
15384 by_kind = {item[
"kind"]: item
for item
in graph[
"providers"]}
15385 requested_set = set(requested
or by_kind)
15386 unknown = requested_set - set(by_kind)
15389 f
"No configured provider exists for asset kind(s): {sorted(unknown)}. "
15390 f
"Configured kinds: {sorted(by_kind)}."
15396 @brief Add one requested asset kind and its dependency closure.
15397 @param[in] kind Asset kind to include.
15400 if kind
in closure:
15403 for dependency
in by_kind[kind].get(
"dependencies", []):
15404 if dependency
in by_kind:
15407 for kind
in requested_set:
15409 selected = [item
for item
in graph[
"providers"]
if item[
"kind"]
in closure]
15410 if precomputable_only:
15411 selected = [item
for item
in selected
if item[
"execution"] ==
"precomputable"]
15417 @brief Enumerate canonical files belonging to one asset kind in a build tree.
15418 @param[in] build_root Temporary run-like build root.
15419 @param[in] kind Asset kind.
15420 @return Absolute payload file paths.
15423 "grid": [
"inputs/grid",
"output/analysis/metrics",
"output/visualization/precompute"],
15424 "initial-condition": [
"inputs/initial_condition",
"output/analysis/spectra",
"output/analysis/metrics"],
15425 "inlet-profiles": [
"inputs/inlet_profiles"],
15428 for relative
in roots:
15429 root = Path(build_root, *relative.split(
"/"))
15431 result.extend(str(path)
for path
in sorted(root.rglob(
"*"))
if path.is_file())
15432 intermediate_names = {
15433 "grid": {
"grid.generated.picgrid",
"grid.converted.picgrid"},
15434 "initial-condition": {
"initial_condition.generated.dat"},
15435 "inlet-profiles": set(),
15438 path
for path
in dict.fromkeys(result)
15439 if os.path.basename(path)
not in intermediate_names
15444 selected: list) -> dict:
15446 @brief Execute existing generators into one isolated run-like build tree.
15447 @param[in] build_root Temporary output root.
15448 @param[in] case_cfg Parsed case config.
15449 @param[in] case_path Owning case path.
15450 @param[in] selected Ordered selected providers.
15451 @return Asset-kind to generated file list.
15454 selected_kinds = {item[
"kind"]
for item
in selected}
15457 def fingerprint(kind):
15459 @brief Snapshot current payload checksums for one asset kind.
15460 @param[in] kind Asset kind to inspect.
15461 @return Absolute-path to checksum mapping.
15468 def record_changes(kind, before):
15470 @brief Record files created or changed by one provider stage.
15471 @param[in] kind Asset kind whose build just completed.
15472 @param[in] before Pre-build checksum mapping.
15475 after = fingerprint(kind)
15477 path
for path, digest
in after.items()
15478 if before.get(path) != digest
15481 grid_cfg = case_cfg.get(
"grid", {})
or {}
15482 scaling = (case_cfg.get(
"properties", {})
or {}).get(
"scaling", {})
or {}
15483 length_ref = float(scaling.get(
"length_ref", 1.0))
15484 expected_nblk = int((case_cfg.get(
"models", {})
or {}).get(
"domain", {}).get(
"blocks", 1))
15485 staged_grid = os.path.join(build_root,
"inputs",
"grid",
"grid.run")
15486 if "grid" in selected_kinds:
15487 before = fingerprint(
"grid")
15488 if grid_cfg.get(
"mode") ==
"grid_gen":
15491 generated, staged_grid, length_ref, expected_nblk=expected_nblk
15493 elif grid_cfg.get(
"mode") ==
"file":
15495 grid_cfg.get(
"source_file"), os.path.dirname(os.path.abspath(case_path))
15498 source, staged_grid, length_ref, expected_nblk=expected_nblk
15500 record_changes(
"grid", before)
15502 if "inlet-profiles" in selected_kinds:
15503 before = fingerprint(
"inlet-profiles")
15505 build_root,
"asset-build", case_cfg,
15506 {
"Case": case_path},
15508 record_changes(
"inlet-profiles", before)
15510 if "initial-condition" in selected_kinds:
15511 before = fingerprint(
"initial-condition")
15514 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
15516 U_ref=fluid_scaling[
"velocity_ref"],
15518 "kinematic_viscosity": fluid_scaling[
"nondimensional_kinematic_viscosity"]
15522 and not os.path.isfile(staged_grid)):
15532 grid_cfg.get(
"programmatic_settings", {}), staged_grid, length_ref
15535 record_changes(
"initial-condition", before)
15542ASSET_PREVIEW_NODE_LIMIT = 2_000_000
15547 @brief Read a canonical PICGRID and summarize what a user would want to check.
15548 @param[in] path Staged PICGRID file.
15549 @return Mapping of block dimensions, bounds, and spacing extremes.
15552 bounds = [[float(
"inf")] * 3, [float(
"-inf")] * 3]
15554 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
15557 for _
in range(2 + len(dims)):
15558 next(iterator,
None)
15559 for _lineno, line
in iterator:
15560 parts = line.split()
15561 if len(parts) != 3:
15564 point = [float(value)
for value
in parts]
15568 for axis
in range(3):
15569 bounds[0][axis] = min(bounds[0][axis], point[axis])
15570 bounds[1][axis] = max(bounds[1][axis], point[axis])
15571 extent = [bounds[1][axis] - bounds[0][axis]
for axis
in range(3)]
if nodes
else [0.0, 0.0, 0.0]
15573 "blocks": len(dims),
15574 "dimensions": [
list(item)
for item
in dims],
15575 "total_nodes": nodes,
15576 "bounds_min": bounds[0]
if nodes
else None,
15577 "bounds_max": bounds[1]
if nodes
else None,
15584 @brief Write a single-block ASCII VTS preview of a staged PICGRID.
15586 @details Only the first block is written: the preview exists so a user can confirm
15587 the shape they configured, not to reproduce the solver's view of a
15588 multi-block domain.
15589 @param[in] picgrid_path Staged PICGRID file.
15590 @param[in] destination Preview path to write.
15591 @param[in] dims Per-block dimension triples.
15592 @return True when a preview was written.
15596 im, jm, km = (int(value)
for value
in dims[0])
15597 if im * jm * km > ASSET_PREVIEW_NODE_LIMIT:
15600 with open(picgrid_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
15602 for _
in range(2 + len(dims)):
15603 next(iterator,
None)
15604 for _lineno, line
in iterator:
15605 parts = line.split()
15606 if len(parts) == 3:
15607 coordinates.append(line)
15608 if len(coordinates) >= im * jm * km:
15610 if len(coordinates) < im * jm * km:
15612 os.makedirs(os.path.dirname(destination), exist_ok=
True)
15613 with open(destination,
"w", encoding=
"utf-8")
as out:
15614 extent = f
"0 {im - 1} 0 {jm - 1} 0 {km - 1}"
15615 out.write(
'<?xml version="1.0"?>\n')
15616 out.write(
'<VTKFile type="StructuredGrid" version="1.0" byte_order="LittleEndian">\n')
15617 out.write(f
' <StructuredGrid WholeExtent="{extent}">\n')
15618 out.write(f
' <Piece Extent="{extent}">\n')
15619 out.write(
' <Points>\n')
15620 out.write(
' <DataArray type="Float64" NumberOfComponents="3" format="ascii">\n')
15621 for row
in coordinates:
15622 out.write(f
" {row}\n")
15623 out.write(
' </DataArray>\n </Points>\n')
15624 out.write(
' </Piece>\n </StructuredGrid>\n</VTKFile>\n')
15629 payload_files: list) -> dict:
15631 @brief Produce the inspection material published beside an asset's payload.
15633 @details Precompute exists so a user can look at a grid, field, or profile and
15634 change it before committing a solve. An asset that carries only opaque
15635 solver input cannot serve that purpose, so every published object gets a
15636 validation record and, where it is affordable, a preview.
15637 @param[in] kind Asset kind.
15638 @param[in] build_root Temporary build tree.
15639 @param[in] provider Provider metadata for the asset.
15640 @param[in] payload_files Absolute payload paths produced for this asset.
15641 @return Mapping of published inspection filename to its absolute source path.
15643 inspection_dir = os.path.join(build_root,
".inspection")
15644 os.makedirs(inspection_dir, exist_ok=
True)
15647 "asset_kind": kind,
15648 "provider": provider[
"provider"],
15649 "generated_at": datetime.now().astimezone().isoformat(),
15650 "files": [os.path.relpath(path, build_root).replace(os.sep,
"/")
for path
in payload_files],
15654 staged = os.path.join(build_root,
"inputs",
"grid",
"grid.run")
15655 if os.path.isfile(staged):
15658 validation[
"geometry"] = geometry
15659 preview = os.path.join(inspection_dir,
"preview.vts")
15661 published[
"preview.vts"] = preview
15663 validation[
"preview"] = (
15664 "skipped: the first block exceeds "
15665 f
"{ASSET_PREVIEW_NODE_LIMIT} nodes"
15667 except (OSError, ValueError, StopIteration)
as exc:
15668 validation[
"geometry_error"] = str(exc)
15669 generator_preview = os.path.join(
15670 build_root,
"output",
"visualization",
"precompute",
"grid.vts"
15672 if os.path.isfile(generator_preview):
15673 published[
"preview.vts"] = generator_preview
15674 validation.pop(
"preview",
None)
15675 info = os.path.join(build_root,
"output",
"analysis",
"metrics",
"grid.info")
15676 if os.path.isfile(info):
15677 published[
"grid.info"] = info
15679 elif kind ==
"initial-condition":
15682 for published_name, suffix
in (
15683 (
"summary.json",
"_summary.json"), (
"spectrum.csv",
"_spectrum.csv"),
15685 for candidate
in payload_files:
15686 base = os.path.basename(candidate)
15687 if base == published_name
or base.endswith(suffix):
15688 published[published_name] = candidate
15690 validation[
"fields"] = sorted(
15691 os.path.basename(path)
for path
in payload_files
if path.endswith(
".dat")
15693 summary_source = published.get(
"summary.json")
15696 with open(summary_source,
"r", encoding=
"utf-8")
as stream:
15697 validation[
"summary"] = json.load(stream)
15698 except (OSError, ValueError):
15701 elif kind ==
"inlet-profiles":
15702 for candidate
in payload_files:
15703 if os.path.basename(candidate) ==
"profile.info":
15704 published[
"profile.info"] = candidate
15705 validation[
"profiles"] = sorted(
15706 os.path.basename(path)
for path
in payload_files
15707 if path.endswith(
".picslice")
15710 validation_path = os.path.join(inspection_dir,
"validation.json")
15712 published[
"validation.json"] = validation_path
15717 payload_files: list) -> dict:
15719 @brief Publish one immutable content-addressed asset object atomically.
15720 @param[in] workspace_root Owning workspace.
15721 @param[in] build_root Temporary build root.
15722 @param[in] provider Provider metadata.
15723 @param[in] payload_files Files produced for the provider.
15724 @return Asset reference written to the asset set.
15726 file_inventory = []
15727 for path
in payload_files:
15728 relative = os.path.relpath(path, build_root).replace(os.sep,
"/")
15729 file_inventory.append({
15731 "bytes": os.path.getsize(path),
15735 "provider_spec_sha256": provider[
"spec_sha256"],
15736 "files": file_inventory,
15741 provider[
"kind"], build_root, provider, payload_files
15743 kind_dir = ASSET_KIND_DIRECTORIES[provider[
"kind"]]
15744 object_root = os.path.join(workspace_root,
"assets",
"objects", kind_dir, asset_id)
15745 if not os.path.isdir(object_root):
15746 parent = os.path.dirname(object_root)
15747 os.makedirs(parent, exist_ok=
True)
15748 temporary = tempfile.mkdtemp(prefix=f
".{asset_id[:12]}-", dir=parent)
15750 for item, source
in zip(file_inventory, payload_files):
15751 destination = os.path.join(temporary,
"payload", *item[
"path"].split(
"/"))
15752 os.makedirs(os.path.dirname(destination), exist_ok=
True)
15753 shutil.copy2(source, destination)
15754 for name, source
in sorted(inspection.items()):
15755 shutil.copy2(source, os.path.join(temporary, name))
15757 "schema_version": ASSET_MANIFEST_SCHEMA_VERSION,
15758 "asset_id": asset_id,
15759 "kind": provider[
"kind"],
15760 "provider": provider[
"provider"],
15761 "provider_execution": provider[
"execution"],
15762 "provider_spec_sha256": provider[
"spec_sha256"],
15763 "provider_spec": provider[
"spec"],
15764 "source_files": provider[
"source_files"],
15765 "software": provider[
"software"],
15766 "created_at": datetime.now().astimezone().isoformat(),
15767 "files": file_inventory,
15768 "inspection": sorted(inspection),
15772 os.replace(temporary, object_root)
15773 except OSError
as exc:
15774 if exc.errno != errno.ENOTEMPTY
and not os.path.isdir(object_root):
15777 if os.path.isdir(temporary):
15778 shutil.rmtree(temporary, ignore_errors=
True)
15780 "asset_id": asset_id,
15781 "kind": provider[
"kind"],
15782 "provider": provider[
"provider"],
15783 "provider_spec_sha256": provider[
"spec_sha256"],
15784 "object": os.path.relpath(object_root, workspace_root).replace(os.sep,
"/"),
15785 "files": file_inventory,
15786 "inspection": sorted(inspection),
15792 @brief Return a collision-free, readable mutable asset-set name.
15793 @param[in] workspace_root Owning initialized workspace.
15794 @param[in] case_path Source case configuration path.
15795 @return Stable asset-set filename stem.
15797 stem = re.sub(
r"[^A-Za-z0-9_.-]+",
"-", Path(case_path).stem).strip(
"-")
or "case"
15799 digest = hashlib.sha256(str(relative).encode(
"utf-8")).hexdigest()[:10]
15800 return f
"{stem}-{digest}"
15804 references: dict) -> str:
15806 @brief Atomically update the named asset set and workspace asset catalog.
15807 @param[in] workspace_root Owning workspace.
15808 @param[in] case_path Source case YAML.
15809 @param[in] graph Complete provider graph.
15810 @param[in] references Newly published or reused asset references.
15811 @return Asset-set YAML path.
15814 set_path = os.path.join(workspace_root,
"assets",
"sets", f
"{name}.yml")
15815 existing =
read_yaml_file(set_path)
if os.path.isfile(set_path)
else {}
15816 assets = dict(existing.get(
"assets")
or {})
15817 assets.update(references)
15819 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
15821 "case": os.path.relpath(case_path, workspace_root).replace(os.sep,
"/"),
15822 "case_sha256": graph[
"case_sha256"],
15823 "updated_at": datetime.now().astimezone().isoformat(),
15825 "runtime_providers": {
15827 "provider": item[
"provider"],
15828 "provider_spec_sha256": item[
"spec_sha256"],
15830 for item
in graph[
"providers"]
if item[
"execution"] ==
"runtime-c"
15834 catalog_path = os.path.join(workspace_root,
"assets",
"catalog.yml")
15835 catalog =
read_yaml_file(catalog_path)
if os.path.isfile(catalog_path)
else {
15836 "schema_version": 1,
"objects": {}
15838 objects = catalog.setdefault(
"objects", {})
15839 for reference
in references.values():
15840 objects[reference[
"asset_id"]] = {
15841 "kind": reference[
"kind"],
15842 "provider": reference[
"provider"],
15843 "object": reference[
"object"],
15850 requested=
None, precomputable_only: bool =
False) -> dict:
15852 @brief Build and publish a selected deterministic asset dependency closure.
15853 @param[in] workspace_root Owning workspace.
15854 @param[in] case_cfg Parsed case configuration.
15855 @param[in] case_path Source case path.
15856 @param[in] requested Requested asset kinds, or all configured providers.
15857 @param[in] precomputable_only Drop runtime-C dependencies from the closure instead of
15858 refusing. For internal run-staging only: a run legitimately
15859 depends on a runtime-C grid that its own solver builds
15860 moments later, so its presence in the dependency closure is
15861 expected there, not an error. The standalone `picurv
15862 precompute` command leaves this False, since nothing will
15863 ever build a runtime-C provider in that context.
15864 @return Provider graph, selected providers, references, and asset-set path.
15868 selected =
_asset_selection(graph, requested, precomputable_only=precomputable_only)
15869 if not precomputable_only:
15870 runtime = [item
for item
in selected
if item[
"execution"] ==
"runtime-c"]
15872 details =
", ".join(f
"{item['kind']}={item['provider']}" for item
in runtime)
15874 "Precompute is atomic and cannot execute simulator-runtime providers. "
15875 f
"Selected dependency graph requires C generation: {details}. "
15876 "Use --only to select an independent precomputable subset, or run the case; "
15877 "the simulator will report each runtime provider before generation."
15880 raise ValueError(
"The selected case has no precomputable providers.")
15881 staging_parent = os.path.join(workspace_root,
"assets")
15882 os.makedirs(staging_parent, exist_ok=
True)
15883 build_root = tempfile.mkdtemp(prefix=
".precompute-", dir=staging_parent)
15887 for provider
in selected:
15888 files = payloads.get(provider[
"kind"], [])
15891 f
"Provider {provider['kind']}={provider['provider']} produced no files."
15894 workspace_root, build_root, provider, files
15897 shutil.rmtree(build_root, ignore_errors=
True)
15899 return {
"graph": graph,
"selected": selected,
"assets": references,
"set_path": set_path}
15904 @brief Return the mutable asset-set pointer associated with a case config name.
15905 @param[in] workspace_root Owning workspace.
15906 @param[in] case_path Source case path.
15907 @return Absolute asset-set YAML path.
15910 return os.path.join(workspace_root,
"assets",
"sets", f
"{name}.yml")
15915 @brief Plan reuse/build/runtime actions for all configured run providers.
15916 @param[in] case_cfg Parsed case configuration.
15917 @param[in] case_path Source case path.
15918 @return Workspace, graph, set path, and per-provider actions.
15926 if os.path.isfile(set_path):
15928 refs = asset_set.get(
"assets", {})
if isinstance(asset_set, dict)
else {}
15930 for provider
in graph[
"providers"]:
15931 reference = refs.get(provider[
"kind"])
if isinstance(refs, dict)
else None
15933 isinstance(reference, dict)
15934 and reference.get(
"provider_spec_sha256") == provider[
"spec_sha256"]
15936 and os.path.isfile(os.path.join(
15937 workspace_root, reference.get(
"object",
""),
"asset.json"
15940 if provider[
"execution"] ==
"runtime-c":
15941 action =
"runtime-c"
15946 actions.append({**provider,
"action": action,
"reference": reference
if matches
else None})
15948 "workspace_root": workspace_root,
15950 "asset_set_path": set_path,
15951 "actions": actions,
15957 @brief Expose one immutable shared-asset file through reflink, hardlink, or copy.
15958 @param[in] source Immutable asset payload file.
15959 @param[in] destination Run-local destination.
15960 @return Materialization mode used.
15962 os.makedirs(os.path.dirname(destination), exist_ok=
True)
15963 temporary = f
"{destination}.tmp.{os.getpid()}"
15965 cp = shutil.which(
"cp")
15967 result = subprocess.run(
15968 [cp,
"--reflink=always",
"--preserve=mode,timestamps", source, temporary],
15969 text=
True, capture_output=
True, check=
False,
15971 if result.returncode == 0:
15972 os.replace(temporary, destination)
15974 if os.path.lexists(temporary):
15975 os.remove(temporary)
15977 os.link(source, temporary)
15978 os.replace(temporary, destination)
15981 if os.path.lexists(temporary):
15982 os.remove(temporary)
15983 shutil.copy2(source, temporary)
15984 os.replace(temporary, destination)
15987 if os.path.lexists(temporary):
15988 os.remove(temporary)
15992 require_precomputed: bool =
False,
15993 fetch_missing: bool =
False) -> dict:
15995 @brief Resolve/build workspace assets and write the exact run input lock.
15996 @param[in] run_dir Run receiving immutable input exposures.
15997 @param[in] case_cfg Parsed case config.
15998 @param[in] case_path Source case path.
15999 @param[in] require_precomputed Refuse any missing deterministic asset.
16000 @param[in] fetch_missing Request remote fetch before local build.
16001 @return Written lock mapping, or a standalone-provider summary outside a workspace.
16004 workspace_root = plan[
"workspace_root"]
16005 if not workspace_root:
16007 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
16010 "runtime_providers": {
16011 item[
"kind"]: item[
"provider"]
for item
in plan[
"actions"]
16012 if item[
"execution"] ==
"runtime-c"
16016 missing = [item
for item
in plan[
"actions"]
if item[
"action"] ==
"build"]
16017 if missing
and fetch_missing:
16021 from .storage
import restore_missing_workspace_assets
16022 except ImportError:
16023 restore_missing_workspace_assets = getattr(
16024 _storage_module,
"restore_missing_workspace_assets",
None
16026 restored = restore_missing_workspace_assets(
16027 workspace_root, [item[
"spec_sha256"]
for item
in missing]
16028 )
if restore_missing_workspace_assets
else {}
16031 item[
"kind"]: restored[item[
"spec_sha256"]]
16032 for item
in missing
if item[
"spec_sha256"]
in restored
16036 workspace_root, case_path, plan[
"graph"], recovered_refs
16039 missing = [item
for item
in plan[
"actions"]
if item[
"action"] ==
"build"]
16040 if missing
and require_precomputed:
16041 names =
", ".join(f
"{item['kind']}={item['provider']}" for item
in missing)
16043 f
"Required precomputed asset(s) are missing or stale: {names}. "
16044 f
"Run 'picurv precompute --case {case_path} --only "
16045 +
",".join(item[
"kind"]
for item
in missing) +
"'."
16049 workspace_root, case_cfg, case_path,
16050 requested=[item[
"kind"]
for item
in missing],
16051 precomputable_only=
True,
16054 still_missing = [item
for item
in plan[
"actions"]
if item[
"action"] ==
"build"]
16057 "Asset generation completed without satisfying: "
16058 +
", ".join(item[
"kind"]
for item
in still_missing)
16062 for item
in plan[
"actions"]:
16063 reference = item.get(
"reference")
16064 if item[
"action"] !=
"reuse" or not isinstance(reference, dict):
16066 object_root = os.path.join(workspace_root, reference[
"object"])
16068 if not isinstance(manifest, dict)
or manifest.get(
"asset_id") != reference.get(
"asset_id"):
16069 raise ValueError(f
"Asset object is missing or invalid: {object_root}")
16071 for file_info
in manifest.get(
"files", []):
16072 relative = file_info[
"path"]
16073 source = os.path.join(object_root,
"payload", *relative.split(
"/"))
16075 raise ValueError(f
"Asset payload checksum mismatch: {source}")
16076 destination = os.path.join(run_dir, *relative.split(
"/"))
16078 exposed.append({
"path": relative,
"mode": mode,
"sha256": file_info[
"sha256"]})
16079 assets[item[
"kind"]] = {**reference,
"exposed": exposed}
16080 print(f
"[INFO] Asset {item['kind']}: reuse {reference['asset_id']}")
16081 runtime_providers = {
16083 "provider": item[
"provider"],
16084 "provider_spec_sha256": item[
"spec_sha256"],
16086 for item
in plan[
"actions"]
if item[
"execution"] ==
"runtime-c"
16088 for kind, provider
in runtime_providers.items():
16089 print(f
"[INFO] Runtime provider {kind}: {provider['provider']} (generated by simulator)")
16091 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
16092 "workspace": workspace_root,
16093 "case_sha256": plan[
"graph"][
"case_sha256"],
16094 "created_at": datetime.now().astimezone().isoformat(),
16096 "runtime_providers": runtime_providers,
16098 write_yaml_file(os.path.join(run_dir,
"inputs",
"assets.lock.yml"), lock)
16105 @brief Resolve, preflight, and atomically publish reusable workspace assets.
16106 @param[in] args Parsed precompute command arguments.
16108 case_path = os.path.abspath(args.case)
16110 if not workspace_root:
16112 "Precompute requires an initialized PICurv workspace. Run 'picurv init ...', "
16113 "then use its config/case.yml."
16116 raw_only = getattr(args,
"only",
None)
16118 if raw_only
and str(raw_only).strip().lower() !=
"all":
16119 requested = [token.strip()
for token
in str(raw_only).split(
",")
if token.strip()]
16122 print(f
"[INFO] Workspace : {workspace_root}")
16123 print(f
"[INFO] Case : {os.path.relpath(case_path, workspace_root)}")
16124 print(
"[INFO] Asset dependency plan:")
16125 for provider
in selected:
16126 dependencies =
",".join(provider.get(
"dependencies", []))
or "none"
16128 f
" - {provider['kind']}: {provider['provider']} "
16129 f
"[{provider['execution']}], dependencies={dependencies}"
16132 for kind, reference
in sorted(result[
"assets"].items()):
16133 print(f
"[SUCCESS] {kind}: {reference['asset_id']} ({reference['object']})")
16134 print(f
"[SUCCESS] Asset set: {os.path.relpath(result['set_path'], workspace_root)}")
16138 @brief Main orchestrator for the 'run' command (local and Slurm modes).
16139 @param[in] args Command-line style argument list supplied to the function.
16141 if getattr(args,
"dry_run",
False):
16144 if plan.get(
"blocking"):
16150 output_dir_abs =
None
16154 statistics_output_paths = []
16155 workflow_start = time.time()
16156 stages_completed = []
16158 submission_meta = {
"launch_mode":
"local",
"no_submit": bool(args.no_submit),
"stages": {}}
16160 cluster_mode = bool(getattr(args,
"cluster",
None))
16162 cluster_path =
None
16163 solver_num_procs_effective = args.num_procs
16164 post_num_procs_effective = args.num_procs
16167 cluster_path = os.path.abspath(args.cluster)
16170 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
16171 if args.scheduler
and args.scheduler.lower() != scheduler_type:
16173 f
"[FATAL] --scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
16177 if scheduler_type !=
"slurm":
16178 print(f
"[FATAL] Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.", file=sys.stderr)
16181 if (args.solve
or args.post_process)
and args.num_procs
not in (1, cluster_tasks):
16183 "[FATAL] In cluster mode, --num-procs must be "
16184 f
"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
16189 solver_num_procs_effective = cluster_tasks
16190 if args.post_process:
16191 post_num_procs_effective = cluster_tasks
16192 submission_meta[
"launch_mode"] =
"slurm"
16193 submission_meta[
"cluster_config"] = cluster_path
16194 submission_meta[
"no_submit"] = bool(args.no_submit)
16195 if args.solve
and args.post_process:
16197 f
"[INFO] Cluster mode enabled (Slurm). Solver and post stages use "
16198 f
"{solver_num_procs_effective} MPI tasks from cluster.yml."
16201 print(f
"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks from cluster.yml.")
16203 print(f
"[INFO] Cluster mode enabled (Slurm). Post stage uses {post_num_procs_effective} MPI tasks from cluster.yml.")
16204 elif getattr(args,
"scheduler",
None):
16205 print(
"[FATAL] --scheduler requires --cluster in this version.", file=sys.stderr)
16210 if getattr(args,
'restart_from',
None):
16211 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
16212 if getattr(args,
'continue_run',
False)
and not args.post_process:
16213 print(
"[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
16217 case_input_path = os.path.abspath(args.case)
16218 workspace_root =
find_workspace_root(case_input_path, args.solver, args.monitor, os.getcwd())
16224 'case':
read_yaml_file(args.case),
'case_path': case_input_path,
16225 'solver':
read_yaml_file(args.solver),
'solver_path': os.path.abspath(args.solver),
16226 'monitor':
read_yaml_file(args.monitor),
'monitor_path': os.path.abspath(args.monitor),
16227 'walltime_guard_policy': walltime_guard_policy,
16228 'statistics_state': getattr(args,
"statistics_state",
None)
or "reset",
16231 print(
"\n[INFO] Validating configuration files...")
16233 configs[
'case'], configs[
'solver'], configs[
'monitor'],
16234 args.case, args.solver, args.monitor
16236 print(
"[SUCCESS] All configuration files passed validation.\n")
16238 continue_mode = getattr(args,
'continue_run',
False)
16241 if not args.run_dir:
16243 run_dir = os.path.abspath(args.run_dir)
16244 if not os.path.isdir(run_dir):
16246 ERROR_CODE_CFG_FILE_NOT_FOUND,
16249 message=
"Specified run directory not found.",
16252 run_id = os.path.basename(run_dir)
16256 runs_root, configs[
"case"], configs[
"case_path"]
16258 run_dir = os.path.join(runs_root, run_id)
16260 if workspace_root
and os.path.commonpath([os.path.abspath(run_dir), workspace_root]) != workspace_root:
16261 fail_cli_usage(
"A workspace run directory must remain below the owning workspace.")
16262 if not continue_mode
and os.path.exists(run_dir):
16263 raise ValueError(f
"Generated run directory already exists: {run_dir}")
16271 args, configs[
"case"], configs[
"solver"], configs[
"monitor"], run_dir
16273 except ValueError
as e:
16280 ERROR_CODE_CFG_INCONSISTENT_COMBO,
16282 file_path=args.case,
16287 config_dir = os.path.join(run_dir,
"config")
16289 print(f
"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
16291 print(f
"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
16297 "solver": args.solver,
16298 "monitor": args.monitor,
16299 "cluster": cluster_path
if cluster_mode
else None,
16301 continuation=continue_mode,
16306 configs[
"case_path"],
16307 require_precomputed=bool(getattr(args,
"require_precomputed",
False)),
16308 fetch_missing=bool(getattr(args,
"fetch_missing",
False)),
16311 print(
"\n" +
"="*25 +
" SOLVER STAGE " +
"="*25)
16312 source_files = {
'Case': args.case,
'Solver': args.solver,
'Monitor': args.monitor}
16313 generated_config_dir = (
16314 config_dir
if active_config[
"revision"] ==
"initial"
16315 else os.path.join(config_dir,
"history", active_config[
"revision"])
16318 run_dir, run_id, configs[
'monitor'], source_files,
16319 config_dir=generated_config_dir,
16321 if resolved_restart_source_dir:
16322 print(f
"[INFO] Restart source: {resolved_restart_source_dir}")
16324 print(
"[INFO] Continue mode: logs will be appended, not overwritten.")
16329 solver_num_procs_effective,
16331 restart_source_dir=resolved_restart_source_dir,
16332 continue_mode=is_continue,
16333 config_dir=generated_config_dir,
16339 monitor_files.get(
"whitelist"),
16340 monitor_files.get(
"profile"),
16341 *glob.glob(os.path.join(generated_config_dir,
"bcs*.run")),
16352 scheduler_dir = os.path.join(run_dir,
"scheduler")
16353 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
16354 solver_log = os.path.join(scheduler_dir,
"solver_%j.out")
16355 solver_err = os.path.join(scheduler_dir,
"solver_%j.err")
16360 config_search_anchor=args.case,
16361 extra_search_anchors=[cluster_path],
16371 env_vars={
"LOG_LEVEL": configs[
'monitor'].get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
16374 submission_meta[
"stages"][
"solve"] = {
16375 "script": solver_script,
16376 "submitted":
False,
16377 "num_procs_effective": solver_num_procs_effective,
16379 print(f
"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
16380 if not args.no_submit:
16382 submission_meta[
"stages"][
"solve"].update(submit_info)
16383 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
16384 print(f
"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
16385 stages_completed.append(
'solve')
16390 solver_num_procs_effective,
16391 config_search_anchor=configs[
"case_path"],
16393 solver_log = os.path.join(
"scheduler", f
"{run_id}_solver.log")
16394 submission_meta[
"stages"][
"solve"] = {
16395 "command": command,
16397 "log_file": solver_log,
16398 "submitted":
False,
16399 "num_procs_effective": solver_num_procs_effective,
16402 print(f
"[SUCCESS] Staged local solver command: {solver_log}")
16405 with runtime_stage_lock(run_dir,
"solver"):
16407 except StorageError
as exc:
16408 print(f
"[FATAL] {exc}", file=sys.stderr)
16410 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
16411 submission_meta[
"stages"][
"solve"][
"executed"] =
True
16412 submission_meta[
"stages"][
"solve"][
"completed_at"] = datetime.now().isoformat()
16413 stages_completed.append(
'solve')
16416 if args.post_process:
16418 run_dir = os.path.abspath(args.run_dir)
16419 if not os.path.isdir(run_dir):
16420 print(f
"[FATAL] Specified run directory not found: {run_dir}", file=sys.stderr)
16422 print(f
"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
16424 run_id = read_artifact_identity(run_dir)[
"run_id"]
16425 elif not args.solve:
16426 print(
"[FATAL] --post-process requires --run-dir when not used with --solve.", file=sys.stderr)
16429 print(
"\n" +
"="*20 +
" POST-PROCESSING STAGE " +
"="*20)
16430 config_dir = os.path.join(run_dir,
"config")
16433 if not all([case_path, monitor_path, solver_control_path]):
16434 print(f
"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
16436 print(
" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
16437 if not monitor_path:
16438 print(
" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
16439 if not solver_control_path:
16440 print(
" - No '.control' file found.", file=sys.stderr)
16443 print(f
"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
16444 print(f
"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
16452 require_storage_payload_local(
16455 checkpoints=range(requested_start, requested_end + 1, requested_interval),
16457 except StorageError
as exc:
16459 ERROR_CODE_CFG_FILE_NOT_FOUND,
16466 print(
"[INFO] Validating post-processing configuration...")
16468 print(
"[SUCCESS] Post-processing configuration passed validation.\n")
16472 post_cfg[
"_picurv_paths"][
"visualization"],
16473 post_cfg[
"_picurv_paths"][
"statistics"],
16474 post_cfg[
"_picurv_paths"][
"spectra"],
16476 os.makedirs(os.path.join(run_dir, relative), exist_ok=
True)
16478 os.makedirs(os.path.dirname(archived_post_path), exist_ok=
True)
16479 if os.path.abspath(args.post) != os.path.abspath(archived_post_path):
16480 shutil.copy2(args.post, archived_post_path)
16482 solver_sources_deferred = bool(args.solve
and (cluster_mode
or args.no_submit))
16483 allow_source_frontier_scan =
not solver_sources_deferred
16490 continue_requested=getattr(args,
'continue_run',
False),
16491 allow_source_frontier_scan=allow_source_frontier_scan,
16495 if post_stages != set(POST_STAGE_NAMES):
16496 print(f
"[INFO] Post stages selected: {','.join(sorted(post_stages))}")
16498 print(f
"[INFO] Post recipe: {recipe_id}")
16499 print(f
"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
16501 if getattr(args,
'continue_run',
False):
16502 if post_plan[
'resume_recipe_match']:
16503 print(f
"[INFO] Post resume recipe match: yes ({post_plan['resume_match_source']}).")
16505 print(
"[INFO] Post resume recipe match: no. Using the configured start_step for this recipe.")
16506 if post_plan[
'completed_frontier_step']
is not None:
16507 print(f
"[INFO] Completed post frontier: step {post_plan['completed_frontier_step']}")
16509 print(
"[INFO] Completed post frontier: none")
16510 if post_plan[
'source_frontier_deferred']:
16511 print(
"[INFO] Source availability frontier: deferred because the solver stage will populate the requested window before post starts.")
16512 elif post_plan[
'source_frontier_step']
is not None:
16513 print(f
"[INFO] Current source availability frontier: step {post_plan['source_frontier_step']}")
16515 print(
"[INFO] Current source availability frontier: none")
16519 if post_plan[
'skip_reason'] ==
'already-complete-window':
16520 print(
"[INFO] Requested post window is already complete; skipping postprocessor launch.")
16522 elif post_plan[
'skip_reason'] ==
'already-caught-up-to-current-source-frontier':
16523 print(
"[INFO] Post outputs are already caught up to the current fully available source frontier; nothing new to launch right now.")
16524 diagnostic = post_plan.get(
'source_frontier_diagnostic')
or {}
16525 first_incomplete = diagnostic.get(
'first_incomplete_step')
16526 if first_incomplete
is not None:
16527 print(f
"[INFO] First incomplete requested source step: {first_incomplete}")
16529 "[INFO] Closest complete source steps: "
16530 f
"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
16531 f
"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
16533 elif post_plan[
'skip_reason'] ==
'nothing-available-yet':
16534 diagnostic = post_plan.get(
'source_frontier_diagnostic')
or {}
16535 first_incomplete = diagnostic.get(
'first_incomplete_step')
16536 if first_incomplete
is not None:
16538 f
"[INFO] First requested source step {first_incomplete} is incomplete; "
16539 "skipping postprocessor launch for now."
16542 "[INFO] Closest complete source steps: "
16543 f
"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
16544 f
"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
16546 missing_files = diagnostic.get(
'missing_files_for_first_incomplete_step')
or []
16548 print(f
"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
16550 print(
"[INFO] No fully available source steps exist yet in the requested window; skipping postprocessor launch for now.")
16553 f
"[INFO] Effective post window: {post_plan['effective_start_step']}..{post_plan['effective_end_step']} "
16554 f
"(stride {post_plan['step_interval']})"
16557 post_effective_cfg = post_plan[
'effective_post_cfg']
16558 post_io_cfg = post_effective_cfg.get(
'io', {})
16560 output_dir_rel = post_io_cfg[
'output_directory']
16561 output_prefix = post_io_cfg[
'output_filename_prefix']
16562 except KeyError
as e:
16563 print(f
"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
16566 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
16567 os.makedirs(output_dir_abs, exist_ok=
True)
16568 print(f
"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
16570 for stats_path
in statistics_output_paths:
16571 print(f
"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
16577 spectra_execute_now = (
16579 and not args.no_submit
16580 and not post_plan[
'source_frontier_deferred']
16582 if 'spectra' in post_stages
and spectra_execute_now:
16585 post_effective_cfg,
16587 post_plan[
'source_data_directory'],
16588 range(post_plan[
'effective_start_step'],
16589 post_plan[
'effective_end_step'] + 1,
16590 post_plan[
'step_interval']),
16592 for artifact
in spectra_summary[
'artifacts']:
16593 print(f
"[INFO] Spectra output: {os.path.relpath(artifact)}")
16594 elif 'spectra' in post_stages
and not (cluster_mode
and 'fields' in post_stages):
16597 reason = (
"the solver stage has not produced output yet"
16598 if post_plan[
'source_frontier_deferred']
else
16599 "this invocation only stages the post job")
16600 print(f
"[INFO] Spectra deferred: {reason}. Measure them once the run has "
16601 f
"checkpoints with:")
16602 print(f
"[INFO] picurv run --post-process --only spectra "
16603 f
"--run-dir {os.path.relpath(run_dir)} --post {args.post}")
16605 if 'fields' not in post_stages:
16607 print(
"[INFO] Skipping the field post-processor (--only "
16608 f
"{','.join(sorted(post_stages))}).")
16609 stages_completed.append(
'post-process')
16611 source_files_post = {
'Case': case_path,
'Post-Profile': args.post}
16617 solver_control_path,
16618 "-postprocessing_config_file",
16622 scheduler_dir = os.path.join(run_dir,
"scheduler")
16623 os.makedirs(scheduler_dir, exist_ok=
True)
16624 post_script = os.path.join(scheduler_dir,
"post.sbatch")
16625 post_log = os.path.join(scheduler_dir,
"post_%j.out")
16626 post_err = os.path.join(scheduler_dir,
"post_%j.err")
16627 post_cluster_cfg = cluster_cfg
16632 config_search_anchor=case_path,
16633 extra_search_anchors=[cluster_path],
16634 force_num_procs=post_num_procs_effective,
16638 post_plan[
'recipe_fingerprint'],
16640 create_wrapper=
True,
16642 spectra_follow = []
16643 if 'spectra' in post_stages:
16646 spectra_follow = [follow]
16647 print(
"[INFO] Spectra will be measured in the post job, after "
16648 "the field post-processor completes.")
16657 env_vars={
"LOG_LEVEL": monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
16658 follow_commands=spectra_follow,
16660 submission_meta[
"stages"][
"post-process"] = {
16661 "script": post_script,
16662 "submitted":
False,
16663 "num_procs_effective": post_num_procs_effective,
16664 "resume_recipe_match": post_plan[
'resume_recipe_match'],
16665 "resume_bootstrapped": post_plan[
'resume_bootstrapped'],
16666 "resume_match_source": post_plan[
'resume_match_source'],
16667 "effective_start_step": post_plan[
'effective_start_step'],
16668 "effective_end_step": post_plan[
'effective_end_step'],
16669 "completed_frontier_step": post_plan[
'completed_frontier_step'],
16670 "source_frontier_step": post_plan[
'source_frontier_step'],
16671 "source_frontier_deferred": post_plan[
'source_frontier_deferred'],
16672 "recipe_fingerprint": post_plan[
'recipe_fingerprint'],
16674 print(f
"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
16676 if not args.no_submit:
16677 dependency_job =
None
16679 dependency_job = submission_meta.get(
"stages", {}).get(
"solve", {}).get(
"job_id")
16680 submit_info =
submit_sbatch(post_script, dependency=dependency_job)
16681 submission_meta[
"stages"][
"post-process"].update(submit_info)
16682 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
16684 submission_meta[
"stages"][
"post-process"][
"dependency"] = f
"afterok:{dependency_job}"
16685 print(f
"[SUCCESS] Submitted post job: {submit_info['job_id']}")
16686 stages_completed.append(
'post-process')
16691 post_num_procs_effective,
16692 config_search_anchor=case_path,
16693 allow_single_rank_launcher_override=
True,
16694 force_num_procs=post_num_procs_effective,
16698 post_plan[
'recipe_fingerprint'],
16700 create_wrapper=
True,
16702 post_log = os.path.join(
"scheduler", f
"{run_id}_{output_prefix}.log")
16703 submission_meta[
"stages"][
"post-process"] = {
16704 "command": command,
16706 "log_file": post_log,
16707 "submitted":
False,
16708 "num_procs_effective": post_num_procs_effective,
16709 "resume_recipe_match": post_plan[
'resume_recipe_match'],
16710 "resume_bootstrapped": post_plan[
'resume_bootstrapped'],
16711 "resume_match_source": post_plan[
'resume_match_source'],
16712 "effective_start_step": post_plan[
'effective_start_step'],
16713 "effective_end_step": post_plan[
'effective_end_step'],
16714 "completed_frontier_step": post_plan[
'completed_frontier_step'],
16715 "source_frontier_step": post_plan[
'source_frontier_step'],
16716 "source_frontier_deferred": post_plan[
'source_frontier_deferred'],
16717 "recipe_fingerprint": post_plan[
'recipe_fingerprint'],
16720 print(f
"[SUCCESS] Staged local post command: {post_log}")
16724 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
16725 submission_meta[
"stages"][
"post-process"][
"executed"] =
True
16726 submission_meta[
"stages"][
"post-process"][
"completed_at"] = datetime.now().isoformat()
16727 stages_completed.append(
'post-process')
16731 manifest_inputs = {}
16736 if args.post_process:
16740 if submission_meta.get(
"stages"):
16741 write_json_file(os.path.join(run_dir,
"scheduler",
"submission.json"), submission_meta)
16742 asset_lock =
read_yaml_file(os.path.join(run_dir,
"inputs",
"assets.lock.yml")) \
16743 if os.path.isfile(os.path.join(run_dir,
"inputs",
"assets.lock.yml"))
else {}
16747 workspace_root=workspace_root,
16748 launch_mode=
"slurm" if cluster_mode
else "local",
16749 num_procs=solver_num_procs_effective,
16750 post_num_procs=post_num_procs_effective,
16751 stages_requested={
"solve": bool(args.solve),
"post_process": bool(args.post_process)},
16752 stages_completed=stages_completed,
16753 inputs=manifest_inputs,
16754 asset_lock=asset_lock,
16755 submission=submission_meta,
16756 lineage=run_lineage,
16760 if stages_completed:
16761 elapsed = time.time() - workflow_start
16762 mins, secs = divmod(int(elapsed), 60)
16763 hrs, mins = divmod(mins, 60)
16765 time_str = f
"{hrs}h {mins}m {secs}s"
16767 time_str = f
"{mins}m {secs}s"
16769 time_str = f
"{secs}s"
16771 print(
"\n" +
"=" * 60)
16772 print(
" RUN SUMMARY")
16774 print(f
" Run ID : {run_id}")
16775 print(f
" Run directory : {os.path.relpath(run_dir)}")
16776 print(f
" Wall-clock : {time_str}")
16777 print(f
" Stages : {', '.join(stages_completed)}")
16778 print(f
" Launch mode : {'slurm' if cluster_mode else 'local'}")
16780 print(f
" Solver MPI procs: {solver_num_procs_effective}")
16781 if args.post_process:
16782 print(f
" Post MPI procs : {post_num_procs_effective}")
16783 if args.solve
and configs:
16784 total_steps = configs[
'case'].get(
'run_control', {}).get(
'total_steps',
'?')
16785 result_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS[
'output'])
16786 print(f
" Steps run : {total_steps}")
16787 print(f
" Solver output : {os.path.relpath(result_dir)}")
16788 if 'post-process' in stages_completed
and output_dir_abs:
16789 print(f
" Post output : {os.path.relpath(output_dir_abs)}")
16790 for stats_path
in statistics_output_paths:
16791 print(f
" Stats output : {os.path.relpath(stats_path)}")
16795 log_display = os.path.join(run_dir, CANONICAL_RUN_PATHS[
'logs'])
16796 relative_log = os.path.relpath(log_display)
16800 f
"{log_display if relative_log.startswith('..') else relative_log}")
16801 if cluster_mode
or submission_meta.get(
"stages"):
16802 submission_file = os.path.join(run_dir,
"scheduler",
"submission.json")
16803 print(f
" Submission meta: {os.path.relpath(submission_file)}")
16809 @brief Parse a case_index.tsv file back into a list of case entry dicts.
16810 @param[in] tsv_path Path to the case_index.tsv file.
16811 @return List of dicts with keys: index, case_id, run_dir, control_file,
16812 post_recipe_file, log_level, post_prefix.
16815 with open(tsv_path)
as f:
16817 line = line.strip()
16820 parts = line.split(
"\t")
16822 "index": int(parts[0]),
16823 "case_id": parts[1],
16824 "run_dir": parts[2],
16825 "control_file": parts[3],
16826 "post_recipe_file": parts[4],
16827 "log_level": parts[5],
16828 "post_prefix": parts[6],
16835 @brief Study/sweep orchestration using Slurm job arrays.
16836 @param[in] args Command-line style argument list supplied to the function.
16838 study_path = os.path.abspath(args.study)
16839 cluster_path = os.path.abspath(args.cluster)
16851 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
16852 study_id = f
"{study_name}_{timestamp}"
16854 cases_dir = os.path.join(study_dir,
"cases")
16855 scheduler_dir = os.path.join(study_dir,
"scheduler")
16856 results_dir = os.path.join(study_dir,
"output",
"analysis")
16857 for path
in [cases_dir, scheduler_dir, results_dir, os.path.join(study_dir,
"logs")]:
16858 os.makedirs(path, exist_ok=
True)
16860 print(f
"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
16861 base_cfgs = study_cfg[
"base_configs"]
16862 base_paths = {k:
resolve_path(study_path, v)
for k, v
in base_cfgs.items()}
16863 base_snapshot_dir = os.path.join(study_dir,
"base_configs")
16864 os.makedirs(base_snapshot_dir, exist_ok=
True)
16865 portable_study_cfg = copy.deepcopy(study_cfg)
16866 portable_study_cfg[
"base_configs"] = {}
16867 for role
in (
"case",
"solver",
"monitor",
"post"):
16868 snapshot_name = f
"{role}.yml"
16869 snapshot_path = os.path.join(base_snapshot_dir, snapshot_name)
16870 shutil.copy2(base_paths[role], snapshot_path)
16871 portable_study_cfg[
"base_configs"][role] = os.path.join(
"base_configs", snapshot_name)
16872 shutil.copy2(study_path, os.path.join(study_dir,
"study.source.yml"))
16873 write_yaml_file(os.path.join(study_dir,
"study.yml"), portable_study_cfg)
16874 shutil.copy2(cluster_path, os.path.join(study_dir,
"cluster.yml"))
16880 validate_simulation_configs(base_case, base_solver, base_monitor, base_paths[
"case"], base_paths[
"solver"], base_paths[
"monitor"])
16884 if not combinations:
16885 print(
"[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
16887 print(f
"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
16891 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
16893 for idx, combo
in enumerate(combinations):
16894 case_id = f
"case_{idx:04d}"
16895 run_dir = os.path.join(cases_dir, case_id)
16896 config_dir = os.path.join(run_dir,
"config")
16899 case_cfg = copy.deepcopy(base_case)
16900 solver_cfg = copy.deepcopy(base_solver)
16901 monitor_cfg = copy.deepcopy(base_monitor)
16902 post_cfg = copy.deepcopy(base_post)
16903 target_map = {
"case": case_cfg,
"solver": solver_cfg,
"monitor": monitor_cfg,
"post": post_cfg}
16904 for full_key, value
in combo.items():
16905 root, nested = full_key.split(
".", 1)
16906 _deep_set(target_map[root], nested, value)
16912 case_path = os.path.join(config_dir,
"case.yml")
16913 solver_path = os.path.join(config_dir,
"solver.yml")
16914 monitor_path = os.path.join(config_dir,
"monitor.yml")
16915 post_path = os.path.join(config_dir,
"post.yml")
16921 "schema_version": 1,
16922 "revision":
"initial",
16923 "updated_at": datetime.now().astimezone().isoformat(),
16925 "case":
"config/case.yml",
16926 "solver":
"config/solver.yml",
16927 "monitor":
"config/monitor.yml",
16936 source_files = {
'Case': case_path,
'Solver': solver_path,
'Monitor': monitor_path}
16939 "case": case_cfg,
"case_path": case_path,
16940 "solver": solver_cfg,
"solver_path": solver_path,
16941 "monitor": monitor_cfg,
"monitor_path": monitor_path,
16947 [control_file, monitor_files.get(
"whitelist"), monitor_files.get(
"profile"),
16948 *glob.glob(os.path.join(config_dir,
"bcs*.run"))],
16952 if not isinstance(post_cfg.get(
'source_data'), dict):
16953 post_cfg[
'source_data'] = {}
16954 post_cfg[
'source_data'][
'directory'] = source_dir
16957 post_cfg[
"_picurv_paths"][
"visualization"],
16958 post_cfg[
"_picurv_paths"][
"statistics"],
16959 post_cfg[
"_picurv_paths"][
"spectra"],
16961 os.makedirs(os.path.join(run_dir, relative), exist_ok=
True)
16962 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix",
"post")
16963 post_recipe =
generate_post_recipe_file(run_dir, case_id, post_cfg, {
'Case': case_path,
'Post-Profile': post_path}, monitor_cfg)
16965 case_entries.append({
16967 "case_id": case_id,
16968 "run_dir": os.path.abspath(run_dir),
16969 "control_file": control_file,
16970 "post_recipe_file": post_recipe,
16971 "log_level": str(monitor_cfg.get(
"logging", {}).get(
"verbosity",
"INFO")).upper(),
16972 "post_prefix": output_prefix,
16975 "parameters": combo,
16978 os.path.join(run_dir,
"manifest.json"),
16980 run_dir, case_id, workspace_root=workspace_root, launch_mode=
"slurm",
16981 artifact_type=
"study-case", study_id=study_id, case_id=case_id,
16982 num_procs=cluster_tasks, post_num_procs=cluster_tasks,
16983 stages_requested={
"solve":
True,
"post_process":
True},
16990 asset_lock=asset_lock,
16994 with open(case_index_file,
"w")
as f:
16995 for entry
in case_entries:
16999 str(entry[
"index"]),
17002 entry[
"control_file"],
17003 entry[
"post_recipe_file"],
17004 entry[
"log_level"],
17005 entry[
"post_prefix"],
17006 entry[
"solve_diagnostic_args"],
17007 entry[
"post_diagnostic_args"],
17011 print(f
"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
17013 max_idx = len(case_entries) - 1
17014 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
17015 array_spec = f
"0-{max_idx}"
17017 array_spec = f
"{array_spec}%{max_conc}"
17021 solver_array_script = os.path.join(scheduler_dir,
"solver_array.sbatch")
17022 post_array_script = os.path.join(scheduler_dir,
"post_array.sbatch")
17024 solver_array_script,
17025 f
"{study_id}_solve",
17032 os.path.join(scheduler_dir,
"solver_%A_%a.out"),
17033 os.path.join(scheduler_dir,
"solver_%A_%a.err")
17037 f
"{study_id}_post",
17044 os.path.join(scheduler_dir,
"post_%A_%a.out"),
17045 os.path.join(scheduler_dir,
"post_%A_%a.err")
17047 print(f
"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
17049 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
17050 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_aggregate.sbatch")
17052 metrics_aggregate_script,
17053 f
"{study_id}_metrics",
17058 print(f
"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
17061 "launch_mode":
"slurm",
17062 "study_id": study_id,
17063 "solver_array": {
"script": solver_array_script,
"submitted":
False},
17064 "post_array": {
"script": post_array_script,
"submitted":
False},
17065 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
17066 "no_submit": bool(args.no_submit),
17068 if not args.no_submit:
17070 submission[
"solver_array"].update(solver_submit)
17071 submission[
"solver_array"][
"submitted"] =
True
17072 post_submit =
submit_sbatch(post_array_script, dependency=solver_submit[
"job_id"])
17073 submission[
"post_array"].update(post_submit)
17074 submission[
"post_array"][
"submitted"] =
True
17075 submission[
"post_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
17076 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
17077 submission[
"metrics_aggregate"].update(metrics_submit)
17078 submission[
"metrics_aggregate"][
"submitted"] =
True
17079 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
17080 print(f
"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
17081 print(f
"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
17082 print(f
"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
17088 "schema_version": 2,
17089 "artifact_type":
"study",
17090 "study_id": study_id,
17091 "created_at": datetime.now().isoformat(),
17092 "software": dict(PICURV_BUILD),
17093 "study_type": study_cfg.get(
"study_type"),
17094 "num_cases": len(case_entries),
17096 "study_dir": study_dir,
17097 "case_index": case_index_file,
17098 "solver_array_script": solver_array_script,
17099 "post_array_script": post_array_script,
17100 "metrics_table": metrics_csv,
17101 "plots_dir": os.path.join(results_dir,
"plots"),
17103 "submission": submission,
17105 write_json_file(os.path.join(scheduler_dir,
"submission.json"), submission)
17106 write_json_file(os.path.join(study_dir,
"study_manifest.json"), summary)
17107 write_json_file(os.path.join(results_dir,
"summary.json"), {
"study_id": study_id,
"metrics_csv": metrics_csv,
"plots": plots})
17109 print(
"\n" +
"=" * 60)
17110 print(
" STUDY SUMMARY")
17112 print(f
" Study ID : {study_id}")
17113 print(f
" Study directory : {os.path.relpath(study_dir)}")
17114 print(f
" Cases generated : {len(case_entries)}")
17115 print(f
" Array spec : {array_spec}")
17116 print(f
" Solver script : {os.path.relpath(solver_array_script)}")
17117 print(f
" Post script : {os.path.relpath(post_array_script)}")
17119 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
17121 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
17127 @brief Continue a partially-completed Slurm parameter sweep study.
17128 @details Detects incomplete cases, prepares them for continuation (updating
17129 start_step, populating restart directories, regenerating control files),
17130 and submits new solver/post/metrics Slurm jobs. If all cases are already
17131 complete, performs metrics aggregation automatically.
17132 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
17134 study_dir = os.path.abspath(args.study_dir)
17135 manifest_path = os.path.join(study_dir,
"study_manifest.json")
17136 if not os.path.isfile(manifest_path):
17137 print(f
"[FATAL] Study manifest not found: {manifest_path}", file=sys.stderr)
17140 study_id = manifest[
"study_id"]
17142 study_path = os.path.join(study_dir,
"study.yml")
17143 cluster_path = os.path.abspath(args.cluster)
if args.cluster
else os.path.join(study_dir,
"cluster.yml")
17150 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir,
"cluster.yml"))
17151 print(f
"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
17153 scheduler_dir = os.path.join(study_dir,
"scheduler")
17154 cases_dir = os.path.join(study_dir,
"cases")
17155 results_dir = os.path.join(study_dir,
"output",
"analysis")
17156 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
17157 if not os.path.isfile(case_index_file):
17158 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
17163 cold_cases = cold_study_members(study_dir)
17164 if cold_cases
and getattr(args,
"auto_fetch",
False):
17166 "[INFO] --auto-fetch: restoring cold-storage member(s) before continuing: "
17167 +
", ".join(cold_cases)
17170 restore_cold_study_members(study_dir, cold_cases)
17171 except StorageError
as exc:
17172 print(f
"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
17174 cold_cases = cold_study_members(study_dir)
17177 "[FATAL] Study continuation requires payload from cold-storage member(s): "
17178 +
", ".join(cold_cases),
17181 for case_id
in cold_cases:
17182 state = storage_state_summary(os.path.join(cases_dir, case_id))
17184 f
" Restore {case_id} with: picurv storage restore --archive-id "
17185 f
"{state.get('archive_id') or '<archive-id>'}",
17189 " Or pass --auto-fetch to restore them automatically.",
17194 base_cfgs = study_cfg[
"base_configs"]
17195 base_paths = {k:
resolve_path(study_path, v)
for k, v
in base_cfgs.items()}
17199 if len(combinations) != len(parsed_entries):
17201 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
17202 f
"case_index.tsv ({len(parsed_entries)} entries).",
17207 print(f
"\n[INFO] Study: {study_id}")
17208 print(f
"[INFO] Scanning {len(combinations)} case(s) for completion status...")
17210 incomplete_indices = []
17211 all_case_entries = []
17212 for idx, combo
in enumerate(combinations):
17213 case_id = f
"case_{idx:04d}"
17214 entry = parsed_entries[idx]
17215 entry[
"parameters"] = combo
17216 run_dir = entry[
"run_dir"]
17218 effective_case = copy.deepcopy(base_case)
17219 for full_key, value
in combo.items():
17220 root, nested = full_key.split(
".", 1)
17222 _deep_set(effective_case, nested, value)
17224 eff_start = int(effective_case.get(
"run_control", {}).get(
"start_step", 0)
or 0)
17225 except (TypeError, ValueError):
17227 eff_total = int(effective_case[
"run_control"][
"total_steps"])
17228 target = eff_start + eff_total
17230 monitor_cfg =
read_yaml_file(os.path.join(run_dir,
"config",
"monitor.yml"))
17232 entry[
"_status"] = status
17234 if status[
"status"] ==
"complete":
17235 print(f
" {case_id}: complete (step {status['last_step']}/{target})")
17236 elif status[
"status"] ==
"partial":
17237 print(f
" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
17238 incomplete_indices.append(idx)
17240 print(f
" {case_id}: no checkpoint — will re-run from scratch")
17241 incomplete_indices.append(idx)
17243 all_case_entries.append(entry)
17245 if not incomplete_indices:
17246 print(
"\n[INFO] All cases are complete. Running metrics aggregation...")
17249 print(
"\n" +
"=" * 60)
17250 print(
" STUDY CONTINUATION SUMMARY")
17252 print(f
" Study ID : {study_id}")
17253 print(f
" Status : ALL COMPLETE")
17255 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
17257 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
17261 print(f
"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
17264 for idx
in incomplete_indices:
17265 entry = all_case_entries[idx]
17266 status = entry[
"_status"]
17267 if status[
"status"] ==
"partial":
17269 entry[
"run_dir"], entry[
"case_id"],
17270 status[
"last_step"], status[
"target_step"],
17273 elif status[
"status"] ==
"empty":
17274 print(f
"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
17276 solver_array_spec =
",".join(str(i)
for i
in incomplete_indices)
17277 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
17279 solver_array_spec = f
"{solver_array_spec}%{max_conc}"
17281 max_idx = len(combinations) - 1
17282 post_array_spec = f
"0-{max_idx}"
17284 post_array_spec = f
"{post_array_spec}%{max_conc}"
17289 solver_continue_script = os.path.join(scheduler_dir,
"solver_continue_array.sbatch")
17290 post_continue_script = os.path.join(scheduler_dir,
"post_continue_array.sbatch")
17292 solver_continue_script,
17293 f
"{study_id}_solve_cont",
17298 solver_exe, post_exe,
17299 os.path.join(scheduler_dir,
"solver_cont_%A_%a.out"),
17300 os.path.join(scheduler_dir,
"solver_cont_%A_%a.err"),
17303 post_continue_script,
17304 f
"{study_id}_post_cont",
17309 solver_exe, post_exe,
17310 os.path.join(scheduler_dir,
"post_cont_%A_%a.out"),
17311 os.path.join(scheduler_dir,
"post_cont_%A_%a.err"),
17314 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
17315 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_continue_aggregate.sbatch")
17317 metrics_aggregate_script,
17318 f
"{study_id}_metrics_cont",
17323 print(f
"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
17326 "launch_mode":
"slurm",
17327 "study_id": study_id,
17328 "continuation":
True,
17329 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
17330 "solver_continue_array": {
"script": solver_continue_script,
"submitted":
False},
17331 "post_continue_array": {
"script": post_continue_script,
"submitted":
False},
17332 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
17333 "no_submit": bool(args.no_submit),
17335 if not args.no_submit:
17337 submission[
"solver_continue_array"].update(solver_submit)
17338 submission[
"solver_continue_array"][
"submitted"] =
True
17339 post_submit =
submit_sbatch(post_continue_script, dependency=solver_submit[
"job_id"])
17340 submission[
"post_continue_array"].update(post_submit)
17341 submission[
"post_continue_array"][
"submitted"] =
True
17342 submission[
"post_continue_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
17343 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
17344 submission[
"metrics_aggregate"].update(metrics_submit)
17345 submission[
"metrics_aggregate"][
"submitted"] =
True
17346 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
17347 print(f
"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
17348 print(f
"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
17349 print(f
"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
17351 write_json_file(os.path.join(scheduler_dir,
"submission_continue.json"), submission)
17353 manifest[
"continuation"] = {
17354 "continued_at": datetime.now().isoformat(),
17355 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
17356 "submission": submission,
17360 print(
"\n" +
"=" * 60)
17361 print(
" STUDY CONTINUATION SUMMARY")
17363 print(f
" Study ID : {study_id}")
17364 print(f
" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
17365 print(f
" Solver array spec : {solver_array_spec}")
17366 print(f
" Post array spec : {post_array_spec}")
17367 print(f
" Solver script : {os.path.relpath(solver_continue_script)}")
17368 print(f
" Post script : {os.path.relpath(post_continue_script)}")
17369 print(f
" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
17370 if not args.no_submit:
17371 print(f
" [Metrics aggregation will run automatically after post-processing]")
17373 print(f
" [--no-submit] Scripts generated but not submitted.")
17374 print(f
" After manual submission and completion, run:")
17375 print(f
" picurv sweep --reaggregate --study-dir {os.path.relpath(study_dir)}")
17381 @brief Re-run metrics aggregation and plot generation for an existing study.
17382 @param[in] args Parsed CLI arguments with study_dir.
17384 study_dir = os.path.abspath(args.study_dir)
17385 study_path = os.path.join(study_dir,
"study.yml")
17386 if not os.path.isfile(study_path):
17387 print(f
"[FATAL] Study config not found: {study_path}", file=sys.stderr)
17392 case_index_file = os.path.join(study_dir,
"scheduler",
"case_index.tsv")
17393 if not os.path.isfile(case_index_file):
17394 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
17398 cold_cases = cold_study_members(study_dir)
17399 if cold_cases
and getattr(args,
"auto_fetch",
False):
17401 "[INFO] --auto-fetch: restoring cold-storage member(s) before aggregating: "
17402 +
", ".join(cold_cases)
17405 restore_cold_study_members(study_dir, cold_cases)
17406 except StorageError
as exc:
17407 print(f
"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
17409 cold_cases = cold_study_members(study_dir)
17412 "[INFO] Cold-storage member(s) cannot be re-measured; their previously "
17413 "aggregated values are carried forward: " +
", ".join(cold_cases)
17416 if len(combinations) != len(parsed_entries):
17418 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
17419 f
"case_index.tsv ({len(parsed_entries)} entries).",
17425 for idx, combo
in enumerate(combinations):
17426 entry = parsed_entries[idx]
17427 entry[
"parameters"] = combo
17428 case_entries.append(entry)
17430 results_dir = os.path.join(study_dir,
"output",
"analysis")
17434 print(
"\n" +
"=" * 60)
17435 print(
" REAGGREGATION SUMMARY")
17438 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
17440 print(f
" Plots generated : {len(plots)}")
17444_SUMMARY_NUMERIC_RE = re.compile(
r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
17449 @brief Read YAML when present, otherwise return None.
17450 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
17451 @return Value returned by `_read_yaml_if_exists()`.
17453 if not filepath
or not os.path.isfile(filepath):
17456 with open(filepath,
"r", encoding=
"utf-8")
as f:
17457 return yaml.safe_load(f)
17458 except yaml.YAMLError:
17464 @brief Read JSON when present, otherwise return None.
17465 @param[in] filepath Argument passed to `_read_json_if_exists()`.
17466 @return Value returned by `_read_json_if_exists()`.
17468 if not filepath
or not os.path.isfile(filepath):
17470 with open(filepath,
"r", encoding=
"utf-8")
as f:
17471 return json.load(f)
17476 @brief Best-effort integer parsing for summary extraction.
17477 @param[in] value Argument passed to `_parse_int_loose()`.
17478 @return Value returned by `_parse_int_loose()`.
17482 text = str(value).strip()
17489 return int(float(text))
17496 @brief Best-effort float parsing for summary extraction.
17497 @param[in] value Argument passed to `_parse_float_loose()`.
17498 @return Value returned by `_parse_float_loose()`.
17502 text = str(value).strip()
17513 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
17514 @param[in] text Argument passed to `_extract_numeric_tuple()`.
17515 @return Value returned by `_extract_numeric_tuple()`.
17519 return [float(token)
for token
in _SUMMARY_NUMERIC_RE.findall(text)]
17524 @brief Resolve run-local config and artifact paths for summarize.
17525 @param[in] run_dir Argument passed to `_build_summary_context()`.
17526 @return Value returned by `_build_summary_context()`.
17528 run_dir = os.path.abspath(run_dir)
17529 if not os.path.isdir(run_dir):
17531 ERROR_CODE_CFG_FILE_NOT_FOUND,
17534 message=
"Run directory not found.",
17538 config_dir = os.path.join(run_dir,
"config")
17540 "case": os.path.join(config_dir,
"case.yml"),
17541 "solver": os.path.join(config_dir,
"solver.yml"),
17542 "monitor": os.path.join(config_dir,
"monitor.yml"),
17549 io_cfg = monitor_cfg.get(
"io", {})
if isinstance(monitor_cfg, dict)
else {}
17550 scheduler_dir = os.path.join(run_dir,
"scheduler")
17552 profiling_cfg = {
"mode":
"off",
"functions": [],
"timestep_file":
"Profiling_Timestep_Summary.csv",
"final_summary_enabled":
True}
17556 particle_console_output_freq =
None
17557 particle_log_interval =
None
17560 particle_log_interval = io_cfg.get(
"particle_log_interval")
17562 particle_count_cfg =
None
17564 particle_count_cfg = (
17565 case_cfg.get(
"models", {})
17566 .get(
"physics", {})
17567 .get(
"particles", {})
17572 "run_dir": run_dir,
17573 "config_dir": config_dir,
17574 "log_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS[
"logs"]),
17575 "metrics_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS[
"metrics"]),
17576 "scheduler_dir": scheduler_dir,
17577 "monitor_cfg": monitor_cfg,
17578 "case_cfg": case_cfg,
17579 "solver_cfg": solver_cfg,
17580 "config_paths": config_paths,
17581 "manifest": manifest,
17582 "profiling_cfg": profiling_cfg,
17583 "particle_console_output_freq": particle_console_output_freq,
17584 "particle_log_interval": particle_log_interval,
17585 "particle_count_cfg": particle_count_cfg,
17591 @brief Return one explicitly requested copied config or fail with a structured error.
17592 @param[in] context Summary context returned by `_build_summary_context()`.
17593 @param[in] name Config selector name.
17594 @return Parsed config mapping.
17596 path = context[
"config_paths"][name]
17597 cfg = context.get(f
"{name}_cfg")
17598 if not os.path.isfile(path):
17600 ERROR_CODE_CFG_FILE_NOT_FOUND,
17603 message=f
"Copied run config '{name}.yml' was not found.",
17604 hint=
"Use a staged run directory containing the requested copied config.",
17607 if not isinstance(cfg, dict)
or not cfg:
17609 ERROR_CODE_CFG_INVALID_VALUE,
17612 message=f
"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
17620 @brief Build timestep-independent run metadata for summarize.
17621 @param[in] context Summary context returned by `_build_summary_context()`.
17622 @return Curated run metadata mapping.
17624 manifest = context[
"manifest"]
17625 software = manifest.get(
"software")
if isinstance(manifest.get(
"software"), dict)
else {}
17627 "run_id": manifest.get(
"run_id", os.path.basename(context[
"run_dir"])),
17628 "run_dir": context[
"run_dir"],
17629 "created_at": manifest.get(
"created_at"),
17630 "launch_mode": manifest.get(
"launch_mode"),
17631 "release_version": software.get(
"release_version"),
17632 "build_id": software.get(
"build_id"),
17633 "git_commit": software.get(
"git_commit", manifest.get(
"git_commit")),
17634 "solver_num_procs": manifest.get(
"solver_num_procs", manifest.get(
"num_procs")),
17635 "post_num_procs": manifest.get(
"post_num_procs"),
17636 "stages_requested": manifest.get(
"stages_requested"),
17637 "stages_completed_or_submitted": manifest.get(
"stages_completed_or_submitted"),
17643 @brief Build compact turbulence and wall-model selections.
17644 @param[in] turbulence_cfg Case turbulence configuration mapping.
17645 @return Curated turbulence and wall-model mapping.
17648 for key
in (
"les",
"rans",
"wall_function"):
17649 value = turbulence_cfg.get(key)
17650 if isinstance(value, dict):
17652 "enabled": value.get(
"enabled",
True),
17653 "model": value.get(
"model"),
17654 **{k: v
for k, v
in value.items()
if k
not in {
"enabled",
"model"}},
17656 elif value
is not None:
17657 result[key] = value
17663 @brief Build a curated case.yml summary with useful derived quantities.
17664 @param[in] context Summary context returned by `_build_summary_context()`.
17665 @return Curated case configuration mapping.
17668 props = cfg.get(
"properties", {})
17669 scaling = props.get(
"scaling", {})
17670 fluid = props.get(
"fluid", {})
17671 run = cfg.get(
"run_control", {})
17672 grid = cfg.get(
"grid", {})
17673 models = cfg.get(
"models", {})
17674 domain = models.get(
"domain", {})
17675 physics = models.get(
"physics", {})
17676 particles = physics.get(
"particles", {})
17677 start = int(run.get(
"start_step", 0))
17678 total = int(run.get(
"total_steps", 0))
17679 dt = float(run.get(
"dt_physical", 0.0))
17680 length_ref = float(scaling.get(
"length_ref"))
17681 velocity_ref = float(scaling.get(
"velocity_ref"))
17682 density = float(fluid.get(
"density"))
17683 viscosity = float(fluid.get(
"viscosity"))
17685 first_block_faces = {row[
"face"]: row
for row
in prepared_bcs[0]}
17687 "i": first_block_faces[
"-Xi"][
"type"] ==
"PERIODIC",
17688 "j": first_block_faces[
"-Eta"][
"type"] ==
"PERIODIC",
17689 "k": first_block_faces[
"-Zeta"][
"type"] ==
"PERIODIC",
17692 for block_idx, block
in enumerate(prepared_bcs):
17695 "block": block_idx,
17697 {
"face": row[
"face"],
"type": row[
"type"],
"handler": row[
"handler"]}
17704 "start_step": start,
17705 "total_steps": total,
17706 "end_step": start + total,
17708 "duration_physical": total * dt,
17709 "dt_nondimensional": dt * velocity_ref / length_ref,
17712 "length_ref": length_ref,
17713 "velocity_ref": velocity_ref,
17714 "density": density,
17715 "viscosity": viscosity,
17716 "reynolds_number": density * velocity_ref * length_ref / viscosity
if viscosity
else None,
17717 "initial_conditions": props.get(
"initial_conditions", {}),
17720 "mode": grid.get(
"mode"),
17722 "programmatic_settings": grid.get(
"programmatic_settings")
if grid.get(
"mode") ==
"programmatic_c" else None,
17723 "source_file": grid.get(
"source_file"),
17726 "blocks": domain.get(
"blocks", 1),
17727 "dimensionality": physics.get(
"dimensionality",
"3D"),
17728 "periodic": periodic_axes,
17731 "fsi": physics.get(
"fsi", {}),
17732 "particles": particles,
17735 "boundary_conditions": bc_blocks,
17741 @brief Build a curated solver.yml summary with normalized selections.
17742 @param[in] context Summary context returned by `_build_summary_context()`.
17743 @return Curated solver configuration mapping.
17746 strategy = cfg.get(
"strategy", {})
or {}
17748 momentum_cfg = cfg.get(
"momentum_solver", {})
or {}
17749 dualtime = momentum_cfg.get(
"dual_time_picard_jameson_rk", momentum_cfg.get(
"dual_time_picard_rk4", {}))
or {}
17750 newton_krylov = momentum_cfg.get(
"newton_krylov", {})
or {}
17751 poisson = cfg.get(
"poisson_solver", cfg.get(
"pressure_solver", {}))
or {}
17752 operation_mode = cfg.get(
"operation_mode", {})
or {}
17757 if operation_mode.get(
"analytical_type")
is not None:
17759 passthrough = cfg.get(
"petsc_passthrough_options", {})
or {}
17761 "operation_mode": operation_mode,
17764 "central_diff": bool(strategy.get(
"central_diff",
False)),
17765 "tolerances": cfg.get(
"tolerances", {}),
17766 "controls": newton_krylov
if selected ==
"newton_krylov" else dualtime,
17768 "poisson": poisson,
17769 "interpolation": cfg.get(
"interpolation", {
"method":
"Trilinear"}),
17770 "scalar_transport": cfg.get(
"scalar_transport", {}),
17771 "verification": cfg.get(
"verification", {}),
17772 "petsc_passthrough": {
"count": len(passthrough),
"options": sorted(passthrough.keys())},
17778 @brief Build a curated monitor.yml summary with resolved defaults.
17779 @param[in] context Summary context returned by `_build_summary_context()`.
17780 @return Curated monitor configuration mapping.
17783 logging_cfg = cfg.get(
"logging", {})
or {}
17784 io_cfg = cfg.get(
"io", {})
or {}
17788 enabled_petsc = sorted(key
for key, value
in diagnostics[
"petsc"].items()
if value
not in (
False,
None))
17791 "verbosity": logging_cfg.get(
"verbosity",
"WARNING"),
17792 "enabled_functions": logging_cfg.get(
"enabled_functions", []),
17796 "enabled_petsc": enabled_petsc,
17797 "petsc": diagnostics[
"petsc"],
17798 "runtime_memory_log": diagnostics[
"runtime_memory_log"],
17801 "data_output_frequency": io_cfg.get(
"data_output_frequency"),
17803 "particle_log_interval": io_cfg.get(
"particle_log_interval"),
17805 "output": CANONICAL_RUN_PATHS[
"output"],
17806 "restart": CANONICAL_RUN_PATHS[
"restart"],
17807 "logs": CANONICAL_RUN_PATHS[
"logs"],
17808 "analysis": CANONICAL_RUN_PATHS[
"analysis"],
17811 "solver_monitoring": {
17812 "enabled_flags": sorted(flag
for flag, value
in monitoring_flags.items()
if value
not in (
False,
None)),
17813 "flags": monitoring_flags,
17815 "solution_monitoring": solution_monitoring,
17821 @brief Parse Continuity_Metrics.log into latest rows by step plus observed order.
17822 @param[in] filepath Argument passed to `_parse_continuity_metrics_log()`.
17823 @return Value returned by `_parse_continuity_metrics_log()`.
17828 if not os.path.isfile(filepath):
17829 return rows_by_step, step_order
17831 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
17833 line = raw_line.strip()
17834 if not line
or line.startswith(
"-")
or line.startswith(
"Timestep"):
17836 parts = [part.strip()
for part
in raw_line.split(
"|")]
17846 if step
is None or block
is None:
17848 if step != active_step:
17850 step_order.append(step)
17851 rows_by_step[step] = {}
17852 rows_by_step.setdefault(step, {})[block] = {
17854 "max_divergence": max_div,
17855 "max_divergence_location": parts[3],
17856 "rhs_sum": rhs_sum,
17857 "flux_in": flux_in,
17858 "flux_out": flux_out,
17859 "net_flux": net_flux,
17861 return {step:
list(block_rows.values())
for step, block_rows
in rows_by_step.items()}, step_order
17866 @brief Parse Particle_Metrics.log into latest rows by step plus observed order.
17867 @param[in] filepath Argument passed to `_parse_particle_metrics_log()`.
17868 @return Value returned by `_parse_particle_metrics_log()`.
17872 if not os.path.isfile(filepath):
17873 return rows_by_step, step_order
17875 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
17877 line = raw_line.strip()
17878 if not line
or line.startswith(
"-")
or line.startswith(
"Stage"):
17880 parts = [part.strip()
for part
in raw_line.split(
"|")]
17890 "lost_particles_cumulative":
None,
17891 "migrated_particles":
None,
17892 "occupied_cells":
None,
17893 "load_imbalance":
None,
17894 "migration_passes":
None,
17896 if len(parts) >= 9:
17915 rows_by_step[step] = row
17916 step_order.append(step)
17917 return rows_by_step, step_order
17922 @brief Parse per-block momentum convergence logs.
17923 @param[in] log_dir Argument passed to `_parse_momentum_convergence_logs()`.
17924 @return Value returned by `_parse_momentum_convergence_logs()`.
17930 os.path.join(log_dir,
"Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
17931 os.path.join(log_dir,
"Momentum_Solver_Convergence_History_Block_*.log"),
17935 regex = re.compile(
17936 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\(k\):\s*(?P<pseudo_iter>\d+)\s*\|"
17937 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
17938 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
17939 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
17940 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
17941 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
17942 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
17943 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
17944 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
17950 for path
in sorted(path
for pattern
in patterns
for path
in glob.glob(pattern)):
17951 block_match = re.search(
r"Block_(\d+)\.log$", path)
17952 if not block_match:
17954 block = int(block_match.group(1))
17955 sources[block] = path
17956 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
17958 match = regex.search(raw_line)
17961 step = int(match.group(
"step"))
17962 step_order.append(step)
17963 key = (step, block)
17964 if key
not in _state:
17965 _state[key] = {
"accepted_count": 0,
"rejected_count": 0,
"last_accepted":
None,
"last_rejected":
None}
17966 entry = _state[key]
17967 status = match.group(
"status")
17970 "pseudo_iterations": int(match.group(
"pseudo_iter")),
17975 "delta_norm": float(match.group(
"delta")),
17976 "delta_rel": float(match.group(
"delta_rel")),
17977 "residual_norm": float(match.group(
"resid")),
17978 "residual_rel": float(match.group(
"resid_rel")),
17983 if status ==
"accepted":
17984 entry[
"accepted_count"] += 1
17985 entry[
"last_accepted"] = row
17987 entry[
"rejected_count"] += 1
17988 entry[
"last_rejected"] = row
17990 for (step, block), entry
in _state.items():
17991 display_row = entry[
"last_accepted"]
or entry[
"last_rejected"]
17993 rows_by_step.setdefault(step, {})[block] = {
17995 "accepted_count": entry[
"accepted_count"],
17996 "rejected_count": entry[
"rejected_count"],
17999 newton_pattern = os.path.join(log_dir,
"Momentum_Solver_Newton_Krylov_Summary_Block_*.log")
18000 newton_regex = re.compile(
18001 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
18002 r"\s*solver:\s*(?P<solver>[^|]+?)\s*\|"
18005 r"(?:\s*Jacobian:\s*[^|]+?\s*\|\s*Preconditioner:\s*[^|]+?\s*\|)?"
18006 r"\s*reason:\s*(?P<reason>\S+)\s*\|"
18007 r"\s*reason_code:\s*(?P<reason_code>-?\d+)\s*\|\s*newton:\s*(?P<newton>\d+)\s*\|"
18008 r"\s*evals:\s*(?P<evals>\d+)\s*\|\s*krylov:\s*(?P<krylov>\d+)\s*\|"
18009 r"\s*initial:\s*(?P<initial>[-+0-9.eE]+|unavailable)\s*\|"
18010 r"\s*final:\s*(?P<final>[-+0-9.eE]+)\s*\|\s*state:\s*(?P<state>\w+)"
18012 for path
in sorted(glob.glob(newton_pattern)):
18013 block_match = re.search(
r"Block_(\d+)\.log$", path)
18014 if not block_match:
18016 file_block = int(block_match.group(1))
18017 sources[file_block] = path
18018 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
18020 match = newton_regex.search(raw_line)
18023 step = int(match.group(
"step"))
18024 block = int(match.group(
"block"))
18025 initial_text = match.group(
"initial")
18026 step_order.append(step)
18027 rows_by_step.setdefault(step, {})[block] = {
18029 "solver": match.group(
"solver").strip(),
18030 "reason": match.group(
"reason"),
18031 "reason_code": int(match.group(
"reason_code")),
18032 "newton_iterations": int(match.group(
"newton")),
18033 "residual_evaluations": int(match.group(
"evals")),
18034 "krylov_iterations": int(match.group(
"krylov")),
18035 "initial_norm":
None if initial_text ==
"unavailable" else float(initial_text),
18036 "final_norm": float(match.group(
"final")),
18037 "state": match.group(
"state"),
18040 return rows_by_step, sources, step_order
18045 @brief Parse per-block Poisson convergence logs.
18046 @param[in] log_dir Argument passed to `_parse_poisson_convergence_logs()`.
18047 @return Value returned by `_parse_poisson_convergence_logs()`.
18052 pattern = os.path.join(log_dir,
"Poisson_Solver_Convergence_History_Block_*.log")
18053 regex = re.compile(
18054 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
18055 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
18056 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
18059 for path
in sorted(glob.glob(pattern)):
18060 block_match = re.search(
r"Block_(\d+)\.log$", path)
18061 if not block_match:
18063 block = int(block_match.group(1))
18064 sources[block] = path
18065 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
18067 match = regex.search(raw_line)
18070 step = int(match.group(
"step"))
18071 step_order.append(step)
18072 rows_by_step.setdefault(step, {})[block] = {
18074 "iterations": int(match.group(
"iter")),
18075 "unpreconditioned_norm": float(match.group(
"unpre")),
18076 "true_norm": float(match.group(
"true")),
18079 return rows_by_step, sources, step_order
18084 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
18085 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
18086 @return Value returned by `_parse_profiling_timestep_csv()`.
18091 if not os.path.isfile(filepath):
18092 return rows_by_step, step_order
18094 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
18095 reader = csv.DictReader(f)
18100 if step != active_step:
18102 step_order.append(step)
18103 rows_by_step[step] = []
18104 rows_by_step.setdefault(step, []).append(
18106 "function": row.get(
"function"),
18111 return rows_by_step, step_order
18116 @brief Parse Runtime_Memory.log into latest rows by step and final status.
18117 @param[in] filepath Runtime memory log path.
18118 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
18123 latest_sample_row =
None
18124 max_process_change_mb =
None
18125 if not os.path.isfile(filepath):
18126 return rows_by_step, step_order, {
"available":
False}
18128 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
18130 line = raw_line.strip()
18131 if not line
or line.startswith(
"#")
or line.startswith(
"Step"):
18133 parts = line.split()
18147 "reason": parts[7],
18149 if row[
"process_change_mb_max"]
is not None:
18150 max_process_change_mb = (
18151 row[
"process_change_mb_max"]
18152 if max_process_change_mb
is None
18153 else max(max_process_change_mb, row[
"process_change_mb_max"])
18155 if row[
"event"]
in {
"Step",
"Post"}:
18156 rows_by_step[step] = row
18157 step_order.append(step)
18158 latest_sample_row = row
18159 elif row[
"event"]
in {
"Shutdown",
"Final"}:
18163 "available": bool(rows_by_step
or final_row),
18164 "source": filepath,
18165 "final_event": final_row.get(
"event")
if final_row
else None,
18166 "final_reason": final_row.get(
"reason")
if final_row
else None,
18167 "max_process_change_mb": max_process_change_mb,
18168 "latest_sample_row": latest_sample_row,
18169 "final_row": final_row,
18171 return rows_by_step, step_order, meta
18176 @brief Parse solution_convergence.log into latest rows by step plus observed order.
18178 The log format uses pipe-delimited aligned columns. The first line of the
18179 file is a banner (starts with '=') containing the mode tag; the second line
18180 is the column header; the third line is a separator (starts with '-').
18181 Subsequent lines are one data row per timestep.
18183 @param[in] filepath Path to solution_convergence.log.
18184 @return Mapping of step number to a dict of column values.
18188 if not os.path.isfile(filepath):
18189 return rows_by_step, step_order
18194 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
18196 line = raw_line.strip()
18199 if line.startswith(
"="):
18200 m = re.search(
r"\[mode:\s*([\w_]+)", line)
18204 if line.startswith(
"-"):
18206 if col_names
is None:
18207 col_names = [p.strip()
for p
in raw_line.split(
"|")]
18209 parts = [p.strip()
for p
in raw_line.split(
"|")]
18210 if len(parts) < 4
or col_names
is None:
18215 step_order.append(step)
18216 row = {
"mode": mode}
18217 for name, val
in zip(col_names, parts):
18222 if float_val
is not None and (
"." in val
or "e" in val.lower()):
18223 row[name] = float_val
18224 elif int_val
is not None:
18225 row[name] = int_val
18228 rows_by_step[step] = row
18229 return rows_by_step, step_order
18234 @brief Return plausible solver stream logs for local and Slurm runs.
18235 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
18236 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
18237 @return Value returned by `_find_solver_stream_log_candidates()`.
18240 os.path.join(run_dir,
"scheduler",
"*_solver.log"),
18241 os.path.join(run_dir,
"scheduler",
"solver_*.out"),
18242 os.path.join(log_dir,
"*_solver.log"),
18246 for pattern
in patterns:
18247 for path
in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=
True):
18248 if path
not in seen:
18256 @brief Parse sampled particle snapshots from a solver stream log.
18257 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
18258 @return Value returned by `_parse_particle_snapshot_file()`.
18261 if not os.path.isfile(filepath):
18264 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
18265 lines = f.readlines()
18268 while idx < len(lines):
18269 match = re.search(
r"Particle states at step\s+(\d+):", lines[idx])
18273 step = int(match.group(1))
18276 while idx < len(lines):
18277 stripped = lines[idx].strip()
18278 if re.search(
r"Particle states at step\s+\d+:", lines[idx]):
18280 if stripped.startswith(
"|"):
18281 parts = [part.strip()
for part
in stripped.split(
"|")[1:-1]]
18282 if len(parts) >= 6
and parts[0] !=
"Rank":
18290 "velocity": velocity,
18292 "sample_speed": math.sqrt(sum(component * component
for component
in velocity))
if len(velocity) == 3
else None,
18295 elif rows
and (
not stripped
or stripped.startswith(
"Progress:")):
18299 snapshots[step] = rows
18301 snapshots.setdefault(step, [])
18307 @brief Return the nearest earlier snapshot step when available.
18308 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
18309 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
18310 @return Value returned by `_find_previous_snapshot_step()`.
18312 earlier_steps = [candidate
for candidate
in snapshot_steps
if candidate < step]
18313 if not earlier_steps:
18315 return max(earlier_steps)
18320 @brief Compute sampled deltas between two particle snapshot samples.
18321 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
18322 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
18323 @return Value returned by `_compute_particle_snapshot_delta()`.
18326 previous_by_pid = {
18327 row.get(
"pid"): row
18328 for row
in previous_rows
18329 if row.get(
"pid")
is not None
18332 row.get(
"pid"): row
18333 for row
in current_rows
18334 if row.get(
"pid")
is not None
18336 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
18337 if not matched_pids:
18338 return {
"available":
False}
18341 rank_migrations = 0
18344 for pid
in matched_pids:
18345 current_row = current_by_pid[pid]
18346 previous_row = previous_by_pid[pid]
18347 current_pos = current_row.get(
"position")
or []
18348 previous_pos = previous_row.get(
"position")
or []
18349 if len(current_pos) == len(previous_pos)
and current_pos:
18350 displacements.append(
18353 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
18354 for idx
in range(len(current_pos))
18358 current_speed = current_row.get(
"sample_speed")
18359 previous_speed = previous_row.get(
"sample_speed")
18360 if current_speed
is not None and previous_speed
is not None:
18361 speed_changes.append(float(current_speed) - float(previous_speed))
18362 if current_row.get(
"rank")
is not None and previous_row.get(
"rank")
is not None:
18363 if current_row[
"rank"] != previous_row[
"rank"]:
18364 rank_migrations += 1
18365 if current_row.get(
"cell")
and previous_row.get(
"cell"):
18366 if current_row[
"cell"] != previous_row[
"cell"]:
18371 "matched_pids": len(matched_pids),
18372 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
18373 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
18374 "rank_migrations": rank_migrations,
18375 "cell_changes": cell_changes,
18378 payload[
"mean_displacement"] = float(np.mean(displacements))
18379 payload[
"max_displacement"] = float(np.max(displacements))
18381 payload[
"mean_speed_change"] = float(np.mean(speed_changes))
18382 payload[
"max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
18389 rows:
"list[dict]",
18391 particle_console_output_freq,
18392 particle_log_interval,
18393 previous_step:
"int | None" =
None,
18394 previous_rows:
"list[dict] | None" =
None,
18397 @brief Build sampled diagnostics for one particle console snapshot.
18398 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
18399 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
18400 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
18401 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
18402 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
18403 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
18404 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
18405 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
18406 @return Value returned by `_build_particle_snapshot_summary()`.
18414 "sampled_rows": len(rows),
18415 "preview_rows": rows[:preview_rows],
18417 "particle_console_output_frequency": particle_console_output_freq,
18418 "particle_log_interval": particle_log_interval,
18425 duplicate_pid_count = 0
18426 duplicate_cell_count = 0
18429 zero_weight_count = 0
18430 negative_weight_count = 0
18431 unique_pid_count = 0
18435 position_components = [[], [], []]
18436 weight_components = {}
18440 pid = row.get(
"pid")
18441 if pid
is not None:
18442 if pid
in seen_pids:
18443 duplicate_pid_count += 1
18446 rank = row.get(
"rank")
18447 if rank
is not None:
18448 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
18450 cell = row.get(
"cell")
or []
18453 cell_counter[key] = cell_counter.get(key, 0) + 1
18455 position = row.get(
"position")
or []
18456 for idx, value
in enumerate(position[:3]):
18457 if not np.isfinite(value):
18458 if np.isnan(value):
18463 position_components[idx].append(float(value))
18465 velocity = row.get(
"velocity")
or []
18466 if any(
not np.isfinite(value)
for value
in velocity):
18467 for value
in velocity:
18468 if not np.isfinite(value):
18469 if np.isnan(value):
18473 speed = row.get(
"sample_speed")
18474 if speed
is not None and np.isfinite(speed):
18475 speeds.append(float(speed))
18477 weights = row.get(
"weights")
or []
18478 for idx, value
in enumerate(weights):
18479 if not np.isfinite(value):
18480 if np.isnan(value):
18485 numeric = float(value)
18486 weight_components.setdefault(idx, []).append(numeric)
18487 if abs(numeric) <= 1.0e-15:
18488 zero_weight_count += 1
18490 negative_weight_count += 1
18492 unique_pid_count = len(seen_pids)
18493 duplicate_cell_count = sum(1
for count
in cell_counter.values()
if count > 1)
18495 payload[
"sampled_distribution"] = {
18496 "unique_cells": len(cell_counter),
18497 "duplicate_cells": duplicate_cell_count,
18498 "rank_counts": rank_counts,
18499 "unique_pids": unique_pid_count,
18501 payload[
"checks"] = {
18502 "duplicate_pid_count": duplicate_pid_count,
18503 "nan_count": nan_count,
18504 "inf_count": inf_count,
18505 "zero_weight_count": zero_weight_count,
18506 "negative_weight_count": negative_weight_count,
18510 payload[
"speed"] = {
18511 "min": float(np.min(speeds)),
18512 "mean": float(np.mean(speeds)),
18513 "max": float(np.max(speeds)),
18514 "std": float(np.std(speeds)),
18515 "stagnant_count": sum(1
for speed
in speeds
if abs(speed) < 1.0e-6),
18518 fastest_rows = sorted(
18519 [row
for row
in rows
if row.get(
"sample_speed")
is not None],
18520 key=
lambda row: row[
"sample_speed"],
18523 payload[
"top_speeds"] = [
18525 "pid": row.get(
"pid"),
18526 "rank": row.get(
"rank"),
18527 "speed": row.get(
"sample_speed"),
18528 "cell": row.get(
"cell"),
18530 for row
in fastest_rows
18533 if any(position_components):
18534 axes = [
"x",
"y",
"z"]
18535 payload[
"position_bounds"] = {}
18537 for idx, axis
in enumerate(axes):
18538 values = position_components[idx]
18540 payload[
"position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
18541 centroid.append(float(np.mean(values)))
18543 centroid.append(
None)
18544 payload[
"position_centroid"] = centroid
18546 if weight_components:
18547 payload[
"weights"] = {}
18548 for idx, values
in sorted(weight_components.items()):
18549 payload[
"weights"][f
"component_{idx}"] = {
18550 "min": float(np.min(values)),
18551 "max": float(np.max(values)),
18554 delta_summary = {
"available":
False}
18555 if previous_step
is not None and previous_rows:
18557 if delta_summary.get(
"available"):
18558 delta_summary[
"previous_step"] = previous_step
18559 payload[
"delta_from_previous_snapshot"] = delta_summary
18568 particle_console_output_freq,
18569 particle_log_interval,
18572 @brief Locate and summarize a particle console snapshot for one step.
18573 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
18574 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
18575 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
18576 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
18577 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
18578 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
18579 @return Value returned by `_find_particle_snapshot_for_step()`.
18584 rows = snapshots.get(step)
18587 if best
is None or len(rows) > len(best[
"rows"]):
18588 best = {
"source": path,
"rows": rows,
"snapshots": snapshots}
18591 return {
"available":
False}
18594 previous_rows = best[
"snapshots"].get(previous_step, [])
if previous_step
is not None else None
18600 particle_console_output_freq=particle_console_output_freq,
18601 particle_log_interval=particle_log_interval,
18602 previous_step=previous_step,
18603 previous_rows=previous_rows,
18615 convergence_rows=None,
18617 selection_mode: str =
"latest",
18620 @brief Select a step to summarize from available metric artifacts.
18621 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
18622 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
18623 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
18624 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
18625 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
18626 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
18627 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
18628 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
18629 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
18630 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
18631 @return Value returned by `_resolve_summary_step()`.
18633 if memory_rows
is None:
18635 if convergence_rows
is None:
18636 convergence_rows = {}
18637 if step_orders
is None:
18639 available_steps = (
18640 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
18641 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
18643 if not available_steps:
18646 if requested_step
is not None:
18647 return requested_step, sorted(available_steps)
18649 if selection_mode ==
"max_step":
18650 return max(available_steps), sorted(available_steps)
18652 for order
in step_orders:
18654 return order[-1], sorted(available_steps)
18655 return max(available_steps), sorted(available_steps)
18660 @brief Format optional numeric values for summary text output.
18661 @param[in] value Argument passed to `_format_summary_float()`.
18662 @param[in] spec Argument passed to `_format_summary_float()`.
18663 @param[in] missing Argument passed to `_format_summary_float()`.
18664 @return Value returned by `_format_summary_float()`.
18668 return format(value, spec)
18673 @brief Return the newest modification time among one or more summary sources.
18674 @param[in] paths Path string, iterable of paths, or mapping of paths.
18675 @return Newest modification time, or -1.0 when no source exists.
18677 if isinstance(paths, dict):
18678 paths = paths.values()
18679 elif isinstance(paths, str):
18682 for path
in paths
or []:
18683 if path
and os.path.isfile(path):
18684 newest = max(newest, os.path.getmtime(path))
18690 @brief Order observed step sequences by the recency of their source files.
18691 @param[in] sources Pairs of observed steps and filesystem source path(s).
18692 @return Step-order lists sorted so active append sources are considered first.
18695 for priority, (order, paths)
in enumerate(sources):
18698 ranked.sort(key=
lambda item: (-item[0], item[1]))
18699 return [order
for _, _, order
in ranked]
18704 @brief Build a read-only run-step summary from existing PICurv artifacts.
18705 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
18706 @param[in] step Argument passed to `build_run_summary_payload()`.
18707 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
18708 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
18709 @return Value returned by `build_run_summary_payload()`.
18712 log_dir = context[
"log_dir"]
18713 continuity_path = os.path.join(log_dir,
"Continuity_Metrics.log")
18714 particle_metrics_path = os.path.join(log_dir,
"Particle_Metrics.log")
18721 profiling_rows = {}
18722 profiling_order = []
18723 profiling_path = os.path.join(log_dir, context[
"profiling_cfg"].get(
"timestep_file",
"Profiling_Timestep_Summary.csv"))
18724 if context[
"profiling_cfg"].get(
"mode") !=
"off":
18728 memory_log_file = diagnostics_cfg[
"runtime_memory_log"].get(
"file",
"Runtime_Memory.log")
18729 memory_path = os.path.join(log_dir, memory_log_file)
18732 convergence_log_path = os.path.join(log_dir,
"solution_convergence.log")
18736 (continuity_order, continuity_path),
18737 (particle_order, particle_metrics_path),
18738 (convergence_order, convergence_log_path),
18739 (profiling_order, profiling_path),
18740 (memory_order, memory_path),
18741 (momentum_order, momentum_sources),
18742 (poisson_order, poisson_sources),
18755 step_orders=step_orders,
18756 selection_mode=selection_mode,
18758 if resolved_step
is None:
18760 ERROR_CODE_CFG_FILE_NOT_FOUND,
18763 message=
"No summary-capable run artifacts were found under the run log directory.",
18764 hint=
"Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
18768 if step
is not None and step
not in set(available_steps):
18770 ERROR_CODE_CFG_INVALID_VALUE,
18772 file_path=context[
"run_dir"],
18773 message=f
"Requested step {step} is not present in the available summary artifacts.",
18774 hint=f
"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
18778 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=
lambda row: row[
"block"])
18779 continuity_summary = {
"available": bool(continuity_step_rows),
"blocks": continuity_step_rows}
18780 if continuity_step_rows:
18781 divergence_values = [
18782 abs(row[
"max_divergence"])
18783 for row
in continuity_step_rows
18784 if row[
"max_divergence"]
is not None
18786 continuity_summary[
"max_abs_divergence"] = max(divergence_values)
if divergence_values
else None
18787 continuity_summary[
"net_flux"] = continuity_step_rows[0].get(
"net_flux")
18788 continuity_summary[
"flux_in"] = continuity_step_rows[0].get(
"flux_in")
18789 continuity_summary[
"flux_out"] = continuity_step_rows[0].get(
"flux_out")
18791 momentum_step_rows = [row
for _, row
in sorted(momentum_rows.get(resolved_step, {}).items())]
18792 momentum_summary = {
"available": bool(momentum_step_rows),
"blocks": momentum_step_rows}
18794 poisson_step_rows = [row
for _, row
in sorted(poisson_rows.get(resolved_step, {}).items())]
18795 poisson_summary = {
"available": bool(poisson_step_rows),
"blocks": poisson_step_rows}
18797 particle_summary = {
"available": resolved_step
in particle_rows}
18798 if resolved_step
in particle_rows:
18799 particle_summary.update(particle_rows[resolved_step])
18801 profiling_summary = {
"available": resolved_step
in profiling_rows}
18802 if resolved_step
in profiling_rows:
18803 functions = sorted(
18804 profiling_rows[resolved_step],
18805 key=
lambda row: (row.get(
"step_time_s")
or 0.0),
18808 profiling_summary[
"functions"] = functions
18809 profiling_summary[
"total_logged_step_time_s"] = sum(
18810 row.get(
"step_time_s")
or 0.0
for row
in functions
18813 memory_summary = {
"available": resolved_step
in memory_rows}
18814 if resolved_step
in memory_rows:
18815 memory_summary.update(memory_rows[resolved_step])
18816 memory_summary[
"source"] = memory_path
18817 memory_summary[
"max_process_change_mb"] = memory_meta.get(
"max_process_change_mb")
18818 memory_summary[
"final_event"] = memory_meta.get(
"final_event")
18819 memory_summary[
"final_reason"] = memory_meta.get(
"final_reason")
18820 memory_summary[
"selected_step"] = resolved_step
18821 memory_summary[
"step_match"] =
True
18822 elif memory_meta.get(
"available"):
18823 latest_sample_row = memory_meta.get(
"latest_sample_row")
18824 if latest_sample_row:
18825 memory_summary.update(latest_sample_row)
18826 memory_summary.update(memory_meta)
18827 memory_summary[
"selected_step"] = resolved_step
18828 memory_summary[
"step_match"] =
False
18830 snapshot_summary = {
"available":
False}
18831 if context[
"particle_console_output_freq"]
and context[
"particle_console_output_freq"] > 0:
18833 context[
"run_dir"],
18836 preview_rows=max(1, snapshot_rows),
18837 particle_console_output_freq=context[
"particle_console_output_freq"],
18838 particle_log_interval=context[
"particle_log_interval"],
18842 "profiling_timestep_mode": context[
"profiling_cfg"].get(
"mode"),
18843 "profiling_timestep_file": context[
"profiling_cfg"].get(
"timestep_file"),
18844 "particle_console_output_frequency": context[
"particle_console_output_freq"],
18845 "particle_log_interval": context[
"particle_log_interval"],
18849 "run_id": context[
"manifest"].get(
"run_id", os.path.basename(context[
"run_dir"])),
18850 "run_dir": context[
"run_dir"],
18851 "step": resolved_step,
18852 "selected_via":
"explicit" if step
is not None else (
"max_step" if selection_mode ==
"max_step" else "latest_available"),
18853 "available_steps": available_steps,
18854 "launch_mode": context[
"manifest"].get(
"launch_mode"),
18855 "created_at": context[
"manifest"].get(
"created_at"),
18856 "monitor": monitor_info,
18857 "particles_configured": context[
"particle_count_cfg"],
18859 "continuity_log": continuity_path
if os.path.isfile(continuity_path)
else None,
18860 "particle_metrics_log": particle_metrics_path
if os.path.isfile(particle_metrics_path)
else None,
18861 "momentum_logs": momentum_sources,
18862 "poisson_logs": poisson_sources,
18863 "profiling_timestep_csv": profiling_path
if os.path.isfile(profiling_path)
else None,
18864 "solution_convergence_log": convergence_log_path
if os.path.isfile(convergence_log_path)
else None,
18865 "runtime_memory_log": memory_path
if os.path.isfile(memory_path)
else None,
18867 "continuity": continuity_summary,
18868 "momentum": momentum_summary,
18869 "poisson": poisson_summary,
18870 "particles": particle_summary,
18871 "particle_snapshot": snapshot_summary,
18872 "profiling": profiling_summary,
18873 "memory": memory_summary,
18874 "convergence": convergence_rows.get(resolved_step)
if convergence_rows
else None,
18880 @brief Render a run-step summary in human or JSON form.
18881 @param[in] payload Argument passed to `render_run_summary()`.
18882 @param[in] output_format Argument passed to `render_run_summary()`.
18884 if output_format ==
"json":
18885 print(json.dumps(payload, indent=2, sort_keys=
True))
18888 print(
"\n" +
"=" * 60)
18889 print(
" RUN STEP SUMMARY")
18891 print(f
" Run ID : {payload.get('run_id')}")
18892 print(f
" Run directory : {os.path.relpath(payload.get('run_dir'))}")
18893 print(f
" Step : {payload.get('step')} ({payload.get('selected_via')})")
18894 if payload.get(
"launch_mode"):
18895 print(f
" Launch mode : {payload.get('launch_mode')}")
18896 if payload.get(
"created_at"):
18897 print(f
" Created at : {payload.get('created_at')}")
18899 continuity = payload.get(
"continuity", {})
18900 print(
"\n Continuity:")
18901 if continuity.get(
"available"):
18902 if continuity.get(
"max_abs_divergence")
is not None:
18903 print(f
" max |div| : {continuity['max_abs_divergence']:.6e}")
18904 if continuity.get(
"net_flux")
is not None:
18905 print(f
" net flux : {continuity['net_flux']:.6e}")
18906 for row
in continuity.get(
"blocks", []):
18909 f
"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
18910 f
"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
18913 print(
" unavailable")
18915 momentum = payload.get(
"momentum", {})
18916 print(
"\n Momentum:")
18917 if momentum.get(
"available"):
18918 for row
in momentum.get(
"blocks", []):
18919 if row.get(
"solver") ==
"Newton Krylov":
18920 print(f
" block {row['block']}: solver=Newton Krylov")
18922 f
" newton={row.get('newton_iterations')} "
18923 f
"krylov={row.get('krylov_iterations')} "
18924 f
"evals={row.get('residual_evaluations')}"
18927 f
" final={_format_summary_float(row.get('final_norm'))} "
18928 f
"reason={row.get('reason')} state={row.get('state')}"
18931 status = row.get(
"status")
or "unknown"
18932 accepted = row.get(
"accepted_count")
18933 rejected = row.get(
"rejected_count")
18934 counts_str = f
" accepted={accepted} rejected={rejected}" if accepted
is not None else ""
18936 dtau_val = row.get(
"dtau")
18937 cfl_in = row.get(
"cfl_eff")
18938 cfl_out = row.get(
"cfl_eff_after")
18939 dtau_out = row.get(
"dtau_after")
18940 if cfl_in
is not None and cfl_out
is not None:
18941 cfl_str = f
"cfl_eff {cfl_in:.4f}->{cfl_out:.4f} dtau {_format_summary_float(dtau_val)}->{_format_summary_float(dtau_out)}"
18942 elif cfl_in
is not None:
18943 cfl_str = f
"cfl_eff={_format_summary_float(cfl_in, '.4f')} dtau={_format_summary_float(dtau_val)}"
18945 cfl_str =
"cfl_eff=n/a"
18946 ratio = row.get(
"trial_ratio")
18947 smoothed = row.get(
"smoothed_ratio")
18948 if ratio
is not None and smoothed
is not None:
18949 ratio_str = f
" ratio={_format_summary_float(ratio)} (ema={_format_summary_float(smoothed)})"
18950 elif ratio
is not None:
18951 ratio_str = f
" ratio={_format_summary_float(ratio)}"
18954 print(f
" block {row['block']} [{status}]:{counts_str} {cfl_str}{ratio_str}")
18956 f
" resid={_format_summary_float(row.get('residual_norm'))}"
18957 f
" delta={_format_summary_float(row.get('delta_norm'))}"
18960 print(
" unavailable")
18962 poisson = payload.get(
"poisson", {})
18963 print(
"\n Poisson:")
18964 if poisson.get(
"available"):
18965 for row
in poisson.get(
"blocks", []):
18968 f
"block {row['block']}: iter={row['iterations']} "
18969 f
"true={_format_summary_float(row.get('true_norm'))} "
18970 f
"rel={_format_summary_float(row.get('relative_norm'))}"
18973 print(
" unavailable")
18975 convergence = payload.get(
"convergence")
18976 print(
"\n Solution Convergence:")
18977 if convergence
is not None:
18978 mode = convergence.get(
"mode",
"unknown")
18979 ref = convergence.get(
"ref")
18980 print(f
" mode : {mode} (ref={'yes' if ref else 'no'})")
18981 if mode
in (
"steady_deterministic",
"transient"):
18982 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
18983 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
18984 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
18985 elif mode ==
"periodic_deterministic":
18986 ph = convergence.get(
"ph")
18987 per = convergence.get(
"per")
18988 print(f
" phase : {ph}/{per}")
18989 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
18990 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
18991 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
18992 elif mode ==
"statistical_steady":
18993 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} win={_format_summary_float(convergence.get('spd_win'))} win_drift={_format_summary_float(convergence.get('spd_win_abs'))}")
18994 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} win={_format_summary_float(convergence.get('ke_win'))} win_drift={_format_summary_float(convergence.get('ke_win_abs'))}")
18996 print(
" unavailable")
18998 particles = payload.get(
"particles", {})
18999 print(
"\n Particles:")
19000 if particles.get(
"available"):
19001 loss_summary = f
"lost={particles.get('lost_particles')}"
19002 if particles.get(
"lost_particles_cumulative")
is not None:
19004 f
"lost(step/total)={particles.get('lost_particles')}/"
19005 f
"{particles.get('lost_particles_cumulative')}"
19009 f
"total={particles.get('total_particles')} {loss_summary} "
19010 f
"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
19011 f
"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
19014 print(
" unavailable")
19016 memory = payload.get(
"memory", {})
19017 print(
"\n Runtime Memory:")
19018 if memory.get(
"available"):
19019 if memory.get(
"source"):
19020 print(f
" source : {os.path.relpath(memory.get('source'))}")
19021 if memory.get(
"step")
is not None and not memory.get(
"step_match",
True):
19022 print(f
" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
19023 if memory.get(
"event"):
19024 print(f
" event : {memory.get('event')} reason={memory.get('reason', '-')}")
19025 print(f
" process max : {_format_summary_float(memory.get('process_current_mb_max'), '.3f')} MB current, {_format_summary_float(memory.get('process_peak_mb_max'), '.3f')} MB peak")
19026 print(f
" PETSc max : {_format_summary_float(memory.get('petsc_allocated_mb_max'), '.3f')} MB allocated, {_format_summary_float(memory.get('petsc_peak_allocated_mb_max'), '.3f')} MB peak")
19027 print(f
" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
19028 if memory.get(
"final_reason"):
19029 print(f
" final reason : {memory.get('final_reason')}")
19031 print(
" unavailable")
19033 snapshot = payload.get(
"particle_snapshot", {})
19034 if snapshot.get(
"available"):
19035 print(
"\n Particle Snapshot (sampled):")
19036 print(f
" source : {os.path.relpath(snapshot.get('source'))}")
19037 cadence = snapshot.get(
"cadence", {})
19040 f
"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
19041 f
"row interval {cadence.get('particle_log_interval', 'n/a')}"
19043 print(f
" sampled rows : {snapshot.get('sampled_rows')}")
19044 speed = snapshot.get(
"speed", {})
19048 f
"sampled speeds: min={_format_summary_float(speed.get('min'))} "
19049 f
"mean={_format_summary_float(speed.get('mean'))} "
19050 f
"max={_format_summary_float(speed.get('max'))} "
19051 f
"std={_format_summary_float(speed.get('std'))} "
19052 f
"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
19054 bounds = snapshot.get(
"position_bounds", {})
19055 centroid = snapshot.get(
"position_centroid")
19058 for axis
in (
"x",
"y",
"z"):
19060 bound_parts.append(
19061 f
"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
19063 print(f
" sampled bounds: {' '.join(bound_parts)}")
19067 f
"sampled center: ({_format_summary_float(centroid[0])}, "
19068 f
"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
19070 distribution = snapshot.get(
"sampled_distribution", {})
19074 f
"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
19075 f
"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
19076 f
"unique_pids={distribution.get('unique_pids', 'n/a')} "
19077 f
"ranks={distribution.get('rank_counts', {})}"
19079 weights = snapshot.get(
"weights", {})
19082 for component, summary
in sorted(weights.items()):
19083 weight_parts.append(
19084 f
"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
19086 print(f
" sampled weights: {' '.join(weight_parts)}")
19087 checks = snapshot.get(
"checks", {})
19091 f
"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
19092 f
"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
19093 f
"zero_weight={checks.get('zero_weight_count', 0)} "
19094 f
"negative_weight={checks.get('negative_weight_count', 0)}"
19096 top_speeds = snapshot.get(
"top_speeds", [])
19098 summary =
", ".join(
19099 f
"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
19100 for row
in top_speeds
19102 print(f
" top speeds : {summary}")
19103 delta_summary = snapshot.get(
"delta_from_previous_snapshot", {})
19104 if delta_summary.get(
"available"):
19107 f
"vs prev snap : step={delta_summary.get('previous_step')} "
19108 f
"matched_pids={delta_summary.get('matched_pids')} "
19109 f
"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
19110 f
"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
19111 f
"rank_moves={delta_summary.get('rank_migrations')} "
19112 f
"cell_changes={delta_summary.get('cell_changes')} "
19113 f
"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
19115 print(
" preview rows :")
19116 for row
in snapshot.get(
"preview_rows", []):
19119 f
"pid={row.get('pid')} rank={row.get('rank')} "
19120 f
"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
19123 profiling = payload.get(
"profiling", {})
19124 print(
"\n Profiling:")
19125 if profiling.get(
"available"):
19126 print(f
" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
19127 for row
in profiling.get(
"functions", [])[:5]:
19130 f
"{row.get('function')}: calls={row.get('calls')} "
19131 f
"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
19134 print(
" unavailable")
19138_CONFIG_SUMMARY_WIDTH = 78
19143 @brief Format one configuration-summary value for compact text output.
19144 @param[in] value Value to format.
19145 @return Compact human-readable value.
19149 if isinstance(value, bool):
19150 return "enabled" if value
else "disabled"
19151 if isinstance(value, float):
19152 return f
"{value:.6g}"
19153 if isinstance(value, (list, tuple)):
19155 if isinstance(value, dict):
19158 return ", ".join(f
"{key}={_summary_display_value(item)}" for key, item
in value.items())
19164 @brief Print a strong dashboard-style configuration summary header.
19165 @param[in] title Section title.
19166 @param[in] subtitle Optional one-line section subtitle.
19168 print(
"\n" +
"=" * _CONFIG_SUMMARY_WIDTH)
19169 print(f
"{title:^78}")
19171 print(f
"{subtitle:^78}")
19172 print(
"=" * _CONFIG_SUMMARY_WIDTH)
19177 @brief Print an aligned configuration-summary field group.
19178 @param[in] title Group title.
19179 @param[in] rows Sequence of `(label, value)` pairs.
19181 visible_rows = [(label, value)
for label, value
in rows
if value
is not None]
19182 if not visible_rows:
19184 print(f
"\n {title}")
19185 print(f
" {'-' * (len(title) + 1)}")
19186 for label, value
in visible_rows:
19187 print(f
" {label:<32} {_summary_display_value(value)}")
19192 @brief Flatten nested summary mappings into readable dotted field rows.
19193 @param[in] mapping Mapping to flatten.
19194 @param[in] prefix Optional parent-field prefix.
19195 @return Sequence of `(field, value)` pairs.
19198 for key, value
in mapping.items():
19199 label = f
"{prefix}.{key}" if prefix
else str(key)
19200 if isinstance(value, dict)
and value:
19203 rows.append((label, value))
19209 @brief Render run metadata as a compact dashboard.
19210 @param[in] summary Curated run overview mapping.
19216 (
"Run directory", os.path.relpath(summary.get(
"run_dir"))
if summary.get(
"run_dir")
else None),
19217 (
"Created", summary.get(
"created_at")),
19218 (
"Launch mode", summary.get(
"launch_mode")),
19219 (
"PICurv release", summary.get(
"release_version")),
19220 (
"Build", summary.get(
"build_id")),
19221 (
"Git commit", summary.get(
"git_commit")),
19227 (
"Solver MPI processes", summary.get(
"solver_num_procs")),
19228 (
"Post MPI processes", summary.get(
"post_num_procs")),
19229 (
"Stages requested", summary.get(
"stages_requested")),
19230 (
"Stages ready/completed", summary.get(
"stages_completed_or_submitted")),
19237 @brief Render the case summary as a glanceable simulation dashboard.
19238 @param[in] summary Curated case configuration mapping.
19240 run = summary.get(
"run_control", {})
19241 props = summary.get(
"properties", {})
19242 grid = summary.get(
"grid", {})
19243 domain = summary.get(
"domain", {})
19244 physics = summary.get(
"physics", {})
19246 f
"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
19247 f
"Re={_summary_display_value(props.get('reynolds_number'))}"
19253 (
"Step range", f
"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
19254 (
"Physical timestep", run.get(
"dt_physical")),
19255 (
"Nondimensional timestep", run.get(
"dt_nondimensional")),
19256 (
"Physical duration", run.get(
"duration_physical")),
19257 (
"Initial conditions", props.get(
"initial_conditions")),
19261 "Fluid And Scaling",
19263 (
"Reynolds number", props.get(
"reynolds_number")),
19264 (
"Reference length", props.get(
"length_ref")),
19265 (
"Reference velocity", props.get(
"velocity_ref")),
19266 (
"Density", props.get(
"density")),
19267 (
"Viscosity", props.get(
"viscosity")),
19273 (
"Grid mode", grid.get(
"mode")),
19274 (
"Blocks", domain.get(
"blocks")),
19275 (
"Dimensionality", domain.get(
"dimensionality")),
19276 (
"Periodic axes", domain.get(
"periodic")),
19277 (
"MPI grid layout", grid.get(
"processor_layout")),
19278 (
"Grid source", grid.get(
"source_file")),
19281 if grid.get(
"programmatic_settings"):
19286 (
"Particles", physics.get(
"particles")),
19287 (
"FSI", physics.get(
"fsi")),
19288 (
"Turbulence", physics.get(
"turbulence")),
19289 (
"Statistics", physics.get(
"statistics")),
19292 boundary_blocks = summary.get(
"boundary_conditions", [])
19293 if boundary_blocks:
19294 print(
"\n Boundary Conditions")
19295 print(
" --------------------")
19296 print(f
" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
19297 print(f
" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
19298 for block
in boundary_blocks:
19299 for face
in block.get(
"faces", []):
19301 f
" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
19302 f
"{face.get('type', '-'):<12} {face.get('handler', '-')}"
19308 @brief Render the solver summary as a glanceable numerical-method dashboard.
19309 @param[in] summary Curated solver configuration mapping.
19311 momentum = summary.get(
"momentum", {})
19312 poisson = summary.get(
"poisson", {})
19313 operation = summary.get(
"operation_mode", {})
19315 f
"Field: {operation.get('eulerian_field_source', '-')} | "
19316 f
"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
19323 (
"Momentum solver", momentum.get(
"type")),
19324 (
"Central differencing", momentum.get(
"central_diff")),
19325 (
"Poisson method", poisson.get(
"method")),
19326 (
"Interpolation", summary.get(
"interpolation")),
19327 (
"Convergence mode", summary.get(
"solution_convergence", {}).get(
"mode")),
19331 control_heading = (
19332 "Newton--Krylov Controls" if momentum.get(
"type") ==
"newton_krylov"
19333 else "Dual-Time Pseudo-Time Controls" if momentum.get(
"type") ==
"DUALTIME_PICARD_JAMESON_RK"
19334 else "Momentum Controls"
19341 passthrough = summary.get(
"petsc_passthrough", {})
19343 "Advanced PETSc Options",
19344 [(
"Option count", passthrough.get(
"count")), (
"Option names", passthrough.get(
"options"))],
19350 @brief Render the monitor summary as a glanceable observability dashboard.
19351 @param[in] summary Curated monitor configuration mapping.
19353 logging_cfg = summary.get(
"logging", {})
19354 profiling = summary.get(
"profiling", {})
19355 diagnostics = summary.get(
"diagnostics", {})
19356 io_cfg = summary.get(
"io", {})
19357 memory_log = diagnostics.get(
"runtime_memory_log", {})
19359 f
"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
19360 f
"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
19366 (
"Verbosity", logging_cfg.get(
"verbosity")),
19367 (
"Enabled functions", logging_cfg.get(
"enabled_functions")),
19374 (
"Field output", io_cfg.get(
"data_output_frequency")),
19375 (
"Particle snapshots", io_cfg.get(
"particle_console_output_frequency")),
19376 (
"Particle row interval", io_cfg.get(
"particle_log_interval")),
19383 (
"Enabled PETSc diagnostics", diagnostics.get(
"enabled_petsc")),
19384 (
"Runtime memory log", memory_log.get(
"enabled")),
19385 (
"Runtime memory file", memory_log.get(
"file")),
19389 solver_monitoring = summary.get(
"solver_monitoring", {})
19391 "Solver Monitoring",
19393 (
"Enabled flags", solver_monitoring.get(
"enabled_flags")),
19394 (
"All flags", solver_monitoring.get(
"flags")),
19401 @brief Render selected timestep-independent config views and optional health.
19402 @param[in] payload Combined selected summary payload.
19403 @param[in] output_format Output format.
19405 if output_format ==
"json":
19406 json_payload = {key: value
for key, value
in payload.items()
if key !=
"_health_requested"}
19407 print(json.dumps(json_payload, indent=2, sort_keys=
True))
19410 if payload.get(
"run_overview")
is not None:
19413 "case": _render_case_summary_text,
19414 "solver": _render_solver_summary_text,
19415 "monitor": _render_monitor_summary_text,
19417 for key
in (
"case",
"solver",
"monitor"):
19418 if key
in payload.get(
"configuration", {}):
19419 renderers[key](payload[
"configuration"][key])
19420 storage = payload.get(
"storage")
19421 if isinstance(storage, dict):
19424 print(f
" State : {storage.get('state', 'LOCAL')}")
19425 if storage.get(
"archive_id"):
19426 print(f
" Archive ID : {storage['archive_id']}")
19427 if storage.get(
"label"):
19428 print(f
" Label : {storage['label']}")
19429 if payload.get(
"_health_requested"):
19431 key: value
for key, value
in payload.items()
19432 if key
not in {
"run_overview",
"configuration",
"storage",
"_health_requested"}
19437_SUMMARY_PLOT_LOG_SCALE_FIELDS = {
19438 "max_divergence",
"delta_norm",
"delta_rel",
"residual_norm",
"residual_rel",
19439 "unpreconditioned_norm",
"true_norm",
"relative_norm",
19440 "u_abs_l2",
"u_rel_l2",
"p_abs_l2",
"p_rel_l2",
19441 "spd_abs",
"spd_rel",
"ke_abs",
"ke_rel",
19442 "spd_win_abs",
"spd_win_rel",
"spd_rms_abs",
"spd_rms_rel",
19443 "ke_win_abs",
"ke_win_rel",
"ke_rms_abs",
"ke_rms_rel",
19444 "parseval_residual",
"zero_mode_energy",
19448_SUMMARY_PLOT_FIELD_LABELS = {
19449 "max_divergence":
"Maximum |divergence|",
19450 "rhs_sum":
"Continuity right-hand-side sum",
19451 "flux_in":
"Inflow flux",
19452 "flux_out":
"Outflow flux",
19453 "net_flux":
"Net boundary flux",
19454 "total_particles":
"Particle count",
19455 "lost_particles":
"Particles lost per step",
19456 "lost_particles_cumulative":
"Cumulative particles lost",
19457 "migrated_particles":
"Migrated particles",
19458 "occupied_cells":
"Occupied cells",
19459 "load_imbalance":
"Particle load imbalance",
19460 "migration_passes":
"Particle migration passes",
19461 "pseudo_iterations":
"Pseudo-iterations to convergence",
19462 "newton_iterations":
"Newton iterations to convergence",
19463 "iterations":
"Linear iterations to convergence",
19464 "dtau":
"Pseudo-time step, Δτ",
19465 "dtau_after":
"Accepted pseudo-time step, Δτ",
19466 "cfl_eff":
"Effective pseudo-CFL",
19467 "cfl_eff_after":
"Accepted effective pseudo-CFL",
19468 "delta_norm":
"Momentum update norm, ‖ΔU‖",
19469 "delta_rel":
"Relative momentum update, ‖ΔU‖/‖ΔU₀‖",
19470 "residual_norm":
"Residual norm",
19471 "residual_rel":
"Relative residual norm",
19472 "trial_ratio":
"Pseudo-CFL trial ratio",
19473 "smoothed_ratio":
"Smoothed pseudo-CFL ratio",
19474 "unpreconditioned_norm":
"Unpreconditioned residual norm",
19475 "true_norm":
"True residual norm",
19476 "relative_norm":
"Relative residual norm",
19477 "calls":
"Calls per physical step",
19478 "step_time_s":
"Wall time per physical step (s)",
19479 "process_current_mb_max":
"Maximum resident memory (MiB)",
19480 "process_peak_mb_max":
"Peak resident memory (MiB)",
19481 "petsc_allocated_mb_max":
"PETSc allocated memory (MiB)",
19482 "petsc_peak_allocated_mb_max":
"Peak PETSc allocated memory (MiB)",
19483 "process_change_mb_max":
"Resident-memory change (MiB)",
19484 "u_abs_l2":
"Velocity L² error",
19485 "u_rel_l2":
"Relative velocity L² error",
19486 "p_abs_l2":
"Pressure L² error",
19487 "p_rel_l2":
"Relative pressure L² error",
19488 "mean_speed":
"Volume-mean speed",
19489 "spd_ref":
"Reference mean speed",
19490 "spd_abs":
"Absolute mean-speed drift",
19491 "spd_rel":
"Relative mean-speed drift",
19492 "mean_ke":
"Mean kinetic energy",
19493 "ke_ref":
"Reference mean kinetic energy",
19494 "ke_abs":
"Absolute kinetic-energy drift",
19495 "ke_rel":
"Relative kinetic-energy drift",
19496 "spd_win":
"Window-mean speed",
19497 "spd_win_prev":
"Previous window-mean speed",
19498 "spd_win_abs":
"Absolute window-mean speed drift",
19499 "spd_win_rel":
"Relative window-mean speed drift",
19500 "spd_rms_win":
"Window RMS mean speed",
19501 "spd_rms_abs":
"Absolute RMS speed drift",
19502 "spd_rms_rel":
"Relative RMS speed drift",
19503 "ke_win":
"Window-mean kinetic energy",
19504 "ke_win_prev":
"Previous window-mean kinetic energy",
19505 "ke_win_abs":
"Absolute window-mean kinetic-energy drift",
19506 "ke_win_rel":
"Relative window-mean kinetic-energy drift",
19507 "ke_rms_win":
"Window RMS kinetic energy",
19508 "ke_rms_abs":
"Absolute RMS kinetic-energy drift",
19509 "ke_rms_rel":
"Relative RMS kinetic-energy drift",
19510 "resolved_kinetic_energy":
"Resolved kinetic energy",
19511 "spectrum_total_energy":
"Integrated spectral energy",
19512 "parseval_residual":
"Parseval residual",
19513 "spectrum_peak_k":
"Peak wavenumber, kₚₑₐₖ",
19514 "zero_mode_energy":
"Zero-mode energy",
19515 "integral_length_scale":
"Integral length scale",
19516 "taylor_microscale":
"Taylor microscale",
19517 "dissipation_over_viscosity":
"Dissipation / kinematic viscosity",
19521_SUMMARY_PLOT_SOURCE_TITLES = {
19522 "continuity":
"Continuity",
19523 "particles":
"Particle transport",
19524 "momentum":
"Momentum solver",
19525 "poisson":
"Pressure Poisson solver",
19526 "profiling":
"Runtime profile",
19527 "memory":
"Runtime memory",
19528 "convergence":
"Solution convergence",
19529 "spectra":
"Turbulence spectrum",
19533_SUMMARY_ITERATION_HISTORY_FIELDS = {
19534 "dtau",
"dtau_after",
"cfl_eff",
"cfl_eff_after",
"delta_norm",
"delta_rel",
19535 "residual_norm",
"residual_rel",
"trial_ratio",
"smoothed_ratio",
19536 "unpreconditioned_norm",
"true_norm",
"relative_norm",
19540_SUMMARY_COUNT_FIELDS = {
19541 "total_particles",
"lost_particles",
"lost_particles_cumulative",
19542 "migrated_particles",
"occupied_cells",
"migration_passes",
"calls",
19543 "pseudo_iterations",
"newton_iterations",
"iterations",
19549 @brief Convert one machine-oriented identifier into a readable plot label.
19550 @param[in] value Dotted path or snake-case identifier.
19551 @return Human-readable label.
19553 text = str(value).split(
".")[-1].replace(
"_",
" ").strip()
19555 "l2":
"L²",
"linf":
"L∞",
"msd":
"mean-squared displacement",
19556 "pct":
"percentage",
"p95":
"95th percentile",
"cfl":
"CFL",
19557 "ke":
"kinetic energy",
"rms":
"RMS",
19559 words = [replacements.get(word.lower(), word)
for word
in text.split()]
19560 return " ".join(words[:1]).capitalize() + (
" " +
" ".join(words[1:])
if len(words) > 1
else "")
19565 @brief Return the report-facing label for one logged scalar field.
19566 @param[in] field Logged scalar field name.
19567 @return Report-facing label.
19574 @brief Resolve a record's physical time from its artifact or copied case configuration.
19575 @param[in] context Summary context with copied case configuration.
19576 @param[in] record Collected plot record.
19577 @return Physical time, or None when it cannot be resolved.
19579 explicit = record.get(
"coordinates", {}).get(
"time")
19580 if explicit
is not None:
19581 return float(explicit)
19583 dt = float((context.get(
"case_cfg")
or {}).get(
"run_control", {}).get(
"dt_physical"))
19584 except (TypeError, ValueError):
19586 return float(record[
"step"]) * dt
if math.isfinite(dt)
and dt > 0.0
else None
19591 @brief Yields `(segment, row)` for each data line of a runtime diagnostics CSV.
19593 The solver's runtime CSVs share one shape: a `step,...` header written once, data
19594 rows appended across the run, and a comment marker at each seam where a continuation
19595 resumed. Rows before the header, or whose width disagrees with it, are skipped rather
19596 than guessed at. `segment` counts the continuations seen so far, so a caller can keep
19597 restarts as separate series instead of drawing a line across the seam.
19599 @param path Diagnostics CSV to read.
19600 @return Generator of `(segment_index, {column: text})` pairs.
19604 with open(path,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as handle:
19605 for raw_line
in handle:
19609 if raw_line.lstrip().startswith(
"step,"):
19610 header = [name.strip()
for name
in raw_line.strip().split(
",")]
19612 parts = [part.strip()
for part
in raw_line.strip().split(
",")]
19613 if not header
or len(parts) != len(header):
19615 yield segment, dict(zip(header, parts))
19619 source_path: str, segment: int = 0, coordinates: dict =
None):
19621 @brief Append one numeric append-ordered record for summarize plotting.
19622 @param[out] records Destination record list.
19623 @param[in] source Qualified source prefix.
19624 @param[in] step Logged timestep.
19625 @param[in] line Human-readable line identity.
19626 @param[in] values Candidate field mapping.
19627 @param[in] source_path Source artifact path.
19628 @param[in] segment Zero-based continuation segment within the source artifact.
19629 @param[in] coordinates Optional independent variables carried by the source row.
19633 for key, value
in values.items()
19634 if isinstance(value, (int, float))
and not isinstance(value, bool)
19636 if step
is not None and numeric:
19642 "source_path": source_path,
19643 "segment": int(segment),
19645 key: value
for key, value
in (coordinates
or {}).items()
19646 if isinstance(value, (int, float))
and not isinstance(value, bool)
19653 @brief Return whether a log line starts a new continuation segment.
19654 @param[in] line Candidate raw or stripped log line.
19655 @return True for the shared continuation marker syntax.
19657 return bool(re.match(
r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
19662 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
19663 @param[in] context Summary context returned by `_build_summary_context()`.
19664 @return Append-ordered plot record list.
19667 log_dir = context[
"log_dir"]
19670 metrics_dir = context[
"metrics_dir"]
19672 continuity_path = os.path.join(log_dir,
"Continuity_Metrics.log")
19673 if os.path.isfile(continuity_path):
19675 with open(continuity_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
19680 parts = [part.strip()
for part
in raw_line.split(
"|")]
19685 records,
"continuity", step, f
"block {block}",
19693 continuity_path, segment,
19696 particle_path = os.path.join(log_dir,
"Particle_Metrics.log")
19697 if os.path.isfile(particle_path):
19699 with open(particle_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
19704 parts = [part.strip()
for part
in raw_line.split(
"|")]
19708 offset = 1
if len(parts) >= 9
else 0
19710 records,
"particles", step,
"particles",
19714 "lost_particles_cumulative":
_parse_int_loose(parts[4])
if offset
else None,
19720 particle_path, segment,
19724 momentum_regex = re.compile(
19725 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\(k\):\s*(?P<pseudo_iter>\d+)\s*\|"
19726 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
19727 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
19728 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
19729 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
19730 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
19731 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
19732 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
19733 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
19735 jameson_patterns = [
19736 os.path.join(log_dir,
"Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
19737 os.path.join(log_dir,
"Momentum_Solver_Convergence_History_Block_*.log"),
19739 for path
in sorted(path
for pattern
in jameson_patterns
for path
in glob.glob(pattern)):
19740 block_match = re.search(
r"Block_(\d+)\.log$", path)
19741 if not block_match:
19744 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
19749 match = momentum_regex.search(raw_line)
19752 records,
"momentum", int(match.group(
"step")), f
"block {block_match.group(1)}",
19754 "pseudo_iterations": int(match.group(
"pseudo_iter")),
19757 "delta_norm": float(match.group(
"delta")),
19758 "delta_rel": float(match.group(
"delta_rel")),
19759 "residual_norm": float(match.group(
"resid")),
19760 "residual_rel": float(match.group(
"resid_rel")),
19767 coordinates={
"solver_iteration": int(match.group(
"pseudo_iter"))},
19770 newton_history_regex = re.compile(
19771 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
19772 r"\s*newton:\s*(?P<newton>\d+)\s*\|\s*nonlinear_norm:\s*(?P<norm>[-+0-9.eE]+)"
19774 for path
in sorted(glob.glob(os.path.join(log_dir,
"Momentum_Solver_Newton_Krylov_History_Block_*.log"))):
19776 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
19781 match = newton_history_regex.search(raw_line)
19784 records,
"momentum", int(match.group(
"step")), f
"block {match.group('block')}",
19786 "newton_iterations": int(match.group(
"newton")),
19787 "residual_norm": float(match.group(
"norm")),
19790 coordinates={
"solver_iteration": int(match.group(
"newton"))},
19793 poisson_regex = re.compile(
19794 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
19795 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
19796 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
19798 for path
in sorted(glob.glob(os.path.join(log_dir,
"Poisson_Solver_Convergence_History_Block_*.log"))):
19800 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
19805 match = poisson_regex.search(raw_line)
19808 records,
"poisson", int(match.group(
"step")), f
"block {match.group('block')}",
19810 "iterations": int(match.group(
"iter")),
19811 "unpreconditioned_norm": float(match.group(
"unpre")),
19812 "true_norm": float(match.group(
"true")),
19816 coordinates={
"solver_iteration": int(match.group(
"iter"))},
19821 les_path = os.path.join(metrics_dir,
"les_coefficient.csv")
19822 if os.path.isfile(les_path):
19844 wall_path = os.path.join(metrics_dir,
"wall_model.csv")
19845 if os.path.isfile(wall_path):
19860 wall_path, segment,
19863 profiling_path = os.path.join(log_dir, context[
"profiling_cfg"].get(
"timestep_file",
"Profiling_Timestep_Summary.csv"))
19864 if os.path.isfile(profiling_path):
19867 with open(profiling_path,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
19872 values = next(csv.reader([raw_line]))
19875 if columns
is None:
19878 row = dict(zip(columns, values))
19880 records,
"profiling",
_parse_int_loose(row.get(
"step")), row.get(
"function")
or "unknown",
19882 profiling_path, segment,
19886 memory_path = os.path.join(log_dir, diagnostics[
"runtime_memory_log"].get(
"file",
"Runtime_Memory.log"))
19887 if os.path.isfile(memory_path):
19889 with open(memory_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
19894 parts = raw_line.split()
19895 if len(parts) >= 8
and parts[1]
in {
"Step",
"Post"}:
19905 memory_path, segment,
19908 convergence_path = os.path.join(log_dir,
"solution_convergence.log")
19909 if os.path.isfile(convergence_path):
19912 with open(convergence_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
19914 line = raw_line.strip()
19918 if not line
or line.startswith((
"=",
"-")):
19920 if columns
is None:
19921 columns = [part.strip()
for part
in raw_line.split(
"|")]
19923 parts = [part.strip()
for part
in raw_line.split(
"|")]
19925 row = dict(zip(columns, parts))
19928 for name, value
in row.items()
19929 if name
not in {
"step",
"time",
"mode",
"ref",
"ph",
"per",
"win"}
19932 records,
"convergence", step,
"convergence", values,
19933 convergence_path, segment,
19943 @brief Collect the per-step scalar histories written by the spectra post stage.
19945 @details Each spectra task keeps its own history file, so one task becomes one
19946 plotted line and several tasks compare directly on the same axes.
19948 @param[in] context Summary context returned by `_build_summary_context()`.
19949 @return Append-ordered plot record list; empty when no spectra were measured.
19952 run_dir = context.get(
"run_dir")
19955 spectra_root = os.path.join(
19958 for root, _dirs, files
in os.walk(spectra_root):
19959 for filename
in sorted(files):
19960 if not filename.endswith(
"_history.csv"):
19962 history_path = os.path.join(root, filename)
19963 line = filename[: -len(
"_history.csv")]
19965 with open(history_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
19966 for row
in csv.DictReader(stream):
19971 for column
in POST_SPECTRA_SCALAR_COLUMNS:
19973 if parsed
is not None:
19974 values[column] = parsed
19977 records,
"spectra", step, line, values, history_path, 0,
19989 @brief Build available qualified-series metadata from plot records.
19990 @param[in] records Append-ordered plot record list.
19991 @return Available series catalog.
19994 for record
in records:
19995 for field
in record[
"values"]:
19996 name = f
"{record['source']}.{field}"
19997 item = catalog.setdefault(name, {
"series": name,
"lines": {},
"source_paths": set(),
"sample_count": 0})
19998 item[
"lines"][record[
"line"]] = item[
"lines"].get(record[
"line"], 0) + 1
19999 item[
"source_paths"].add(record[
"source_path"])
20000 item[
"sample_count"] += 1
20004 "lines": [{
"label": label,
"sample_count": count}
for label, count
in sorted(item[
"lines"].items())],
20005 "source_paths": sorted(item[
"source_paths"]),
20007 for _, item
in sorted(catalog.items())
20013 @brief Select evenly distributed indices while always retaining both endpoints.
20014 @param[in] count Number of available ordered states.
20015 @param[in] maximum Maximum states to retain.
20016 @return Sorted unique zero-based indices.
20018 if count <= maximum:
20019 return list(range(count))
20020 return sorted({round(index * (count - 1) / (maximum - 1))
for index
in range(maximum)})
20024 linear_y: bool, output_path:
"str | None") -> dict:
20026 @brief Build a plot.gen request drawing representative measured spectra.
20028 @details A decaying flow has a different spectrum at every step, so states are
20029 never averaged. Up to six evenly spaced states are retained, including
20030 the first and last, to show evolution without an unreadable legend.
20032 @param[in] context Summary context returned by `_build_summary_context()`.
20033 @param[in] task Task basename, or a unique substring of one.
20034 @param[in] reference Overlay the staged initial-condition spectrum when available.
20035 @param[in] linear_y Force linear axes instead of the log-log default.
20036 @param[in] output_path Optional explicit output path.
20037 @return Versioned normalized plot request.
20038 @throws ValueError when no matching spectrum file exists.
20040 spectra_dir = os.path.join(
20041 context[
"run_dir"],
20045 if os.path.isdir(spectra_dir):
20048 reference_name = os.path.basename(INITIAL_CONDITION_SPECTRUM_RELPATH)
20049 candidates = sorted(
20050 name
for name
in os.listdir(spectra_dir)
20051 if name.endswith(
".csv")
20052 and not name.endswith(
"_history.csv")
20053 and name != reference_name
20057 "No spectra were found for this run. Run "
20058 "'picurv run --post-process --only spectra' first."
20060 matches = [name
for name
in candidates
if task
in name]
if task
else candidates
20061 if len(matches) != 1:
20062 available =
", ".join(name[: -len(
".csv")]
for name
in candidates)
20064 f
"Spectrum selector {task!r} matched {len(matches)} files. Available: {available}."
20066 spectrum_path = os.path.join(spectra_dir, matches[0])
20070 with open(spectrum_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
20071 for row
in csv.DictReader(stream):
20075 if step
is None or wavenumber
is None or energy
is None:
20078 if wavenumber <= 0.0
or (
not linear_y
and energy <= 0.0):
20080 by_step.setdefault(step, []).append([wavenumber, energy])
20083 raise ValueError(f
"{os.path.relpath(spectrum_path)} carries no plottable spectrum rows.")
20087 reference_path = os.path.join(context[
"run_dir"], INITIAL_CONDITION_SPECTRUM_RELPATH)
20088 if os.path.isfile(reference_path):
20090 with open(reference_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
20091 for row
in csv.DictReader(stream):
20094 if wavenumber
is None or energy
is None:
20096 if wavenumber <= 0.0
or (
not linear_y
and energy <= 0.0):
20098 points.append([wavenumber, energy])
20101 "label":
"Initial condition",
"points": sorted(points),
20102 "role":
"reference",
"color":
"#333333",
"line_style":
"--",
20105 ordered_steps = sorted(by_step)
20107 spectrum_colors = (
"#440154",
"#414487",
"#2A788E",
"#22A884",
"#7AD151",
"#DCE319")
20108 for index, step
in enumerate(selected_steps):
20109 time = times.get(step)
20110 label = f
"Step {step}" if time
is None else f
"t = {time:g} (step {step})"
20113 "points": sorted(by_step[step]),
20114 "role":
"latest" if step == ordered_steps[-1]
else "snapshot",
20115 "color": spectrum_colors[round(index * (len(spectrum_colors) - 1) / max(1, len(selected_steps) - 1))],
20116 "line_width": 2.4
if step == ordered_steps[-1]
else 1.65,
20119 name = matches[0][: -len(
".csv")]
20120 fallback = os.path.join(
20121 context[
"run_dir"], CANONICAL_RUN_PATHS[
"plots"], f
"{name}.png"
20123 task_match = re.search(
r"_(?P<field>[^_]+)_block(?P<block>\d+)_(?P<symbol>[^_]+)$", name)
20127 f
"Field {task_match.group('field')} · block {int(task_match.group('block'))} "
20128 f
"· {task_match.group('symbol')} wavenumber"
20131 "schema_version": 1,
20132 "plot_type":
"spectrum",
20134 "title":
"Turbulent kinetic-energy spectrum",
20135 "subtitle": subtitle,
20136 "x_label":
"Wavenumber, k",
20137 "y_label":
"Energy spectrum, E(k)",
20138 "x_scale":
"linear" if linear_y
else "log",
20139 "y_scale":
"linear" if linear_y
else "log",
20140 "legend_title":
"Snapshot",
20141 "show_markers":
False,
20143 "mode":
"representative",
"last":
None,
20144 "available_steps": len(ordered_steps),
"selected_steps": selected_steps,
20147 "output_path": os.path.abspath(output_path)
if output_path
else fallback,
20153 @brief Build one normalized plot.gen request from collected summarize records.
20154 @param[in] context Summary context returned by `_build_summary_context()`.
20155 @param[in] records Append-ordered plot record list.
20156 @param[in] series Qualified series name.
20157 @param[in] last_n Optional last-N records per plotted line.
20158 @param[in] linear_y Whether to force linear scaling.
20159 @param[in] output_path Optional explicit output path.
20160 @return Versioned normalized plot request.
20162 source, separator, field = series.partition(
".")
20164 raise ValueError(
"plot series must be qualified as '<source>.<field>'")
20165 matching = [record
for record
in records
if record[
"source"] == source
and field
in record[
"values"]]
20167 raise ValueError(f
"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
20168 latest_segments = {}
20169 for record
in matching:
20170 source_path = record[
"source_path"]
20171 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get(
"segment", 0))
20173 record
for record
in matching
20174 if record.get(
"segment", 0) == latest_segments[record[
"source_path"]]
20176 is_iteration_history = (
20177 source
in {
"momentum",
"poisson"}
and field
in _SUMMARY_ITERATION_HISTORY_FIELDS
20178 and any(
"solver_iteration" in record.get(
"coordinates", {})
for record
in matching)
20181 selected_steps = set()
20182 if is_iteration_history:
20184 for record
in matching:
20185 iteration = record.get(
"coordinates", {}).get(
"solver_iteration")
20186 if iteration
is None:
20188 histories.setdefault((record[
"source_path"], record[
"line"]), []).append(record)
20189 for (_source_path, label), history
in histories.items():
20190 latest_step = max(record[
"step"]
for record
in history)
20191 selected_steps.add(latest_step)
20193 [record[
"coordinates"][
"solver_iteration"], record[
"values"][field]]
20194 for record
in history
if record[
"step"] == latest_step
20196 if last_n
is not None:
20197 points = points[-last_n:]
20198 grouped.setdefault(f
"{label} · step {latest_step}", []).extend(points)
20199 x_label =
"Nonlinear iteration" if source ==
"momentum" else "Linear iteration"
20203 for record
in matching:
20205 grouped.setdefault(record[
"line"], []).append([x_value, record[
"values"][field]])
20208 if field
in {
"pseudo_iterations",
"newton_iterations",
"iterations"}:
20210 label: [
list(item)
for item
in {
20211 point[0]: max(candidate[1]
for candidate
in points
if candidate[0] == point[0])
20212 for point
in points
20214 for label, points
in grouped.items()
20216 for label, points
in grouped.items():
20217 points.sort(key=
lambda point: point[0])
20218 if last_n
is not None:
20219 grouped[label] = points[-last_n:]
20220 x_label =
"Physical time" if uses_physical_time
else "Physical timestep"
20221 x_kind =
"continuous" if uses_physical_time
else "integer"
20223 grouped = {label: points
for label, points
in grouped.items()
if points}
20225 raise ValueError(f
"Plot series '{series}' has no plottable points in the selected window.")
20226 if is_iteration_history
and len(selected_steps) == 1:
20228 label.rsplit(
" · step ", 1)[0]: points
20229 for label, points
in grouped.items()
20231 all_values = [point[1]
for points
in grouped.values()
for point
in points]
20232 use_log =
not linear_y
and field
in _SUMMARY_PLOT_LOG_SCALE_FIELDS
and all(value > 0
for value
in all_values)
20233 window_token = f
"last-{last_n}" if last_n
is not None else "full"
20234 safe_series = re.sub(
r"[^A-Za-z0-9_.-]+",
"_", series)
20235 fallback = os.path.join(
20236 context[
"run_dir"], CANONICAL_RUN_PATHS[
"plots"],
20237 f
"{safe_series}_{window_token}.png",
20241 title = f
"{source_title}: {field_label}"
20242 if is_iteration_history:
20243 title +=
" convergence"
20246 ordered = sorted(selected_steps)
20247 subtitle_bits.append(
20248 f
"Latest physical step {ordered[0]}" if len(ordered) == 1
20249 else f
"Latest available physical steps {ordered[0]}–{ordered[-1]}"
20251 if last_n
is not None:
20252 subtitle_bits.append(f
"Last {last_n} samples per curve")
20254 "continuity":
"Block",
20255 "momentum":
"Block",
20256 "poisson":
"Block",
20257 "profiling":
"Function",
20258 "spectra":
"Spectrum task",
20259 }.get(source,
"Series")
20260 only_label = next(iter(grouped))
if len(grouped) == 1
else None
20261 show_legend = len(grouped) > 1
or bool(
20262 only_label
and (only_label.lower().startswith(
"block ")
or source ==
"profiling")
20265 "schema_version": 1,
20266 "plot_type":
"iteration_history" if is_iteration_history
else "time_history",
20269 "subtitle":
" · ".join(subtitle_bits)
or None,
20270 "x_label": x_label,
20272 "y_label": field_label,
20273 "y_scale":
"log" if use_log
else "linear",
20274 "include_zero_y":
not use_log
and all(value >= 0.0
for value
in all_values),
20275 "show_markers":
not is_iteration_history,
20276 "show_legend": show_legend,
20277 "legend_title": legend_title,
20278 "window": {
"mode":
"last" if last_n
is not None else "full",
"last": last_n},
20279 "lines": [{
"label": label,
"points": points}
for label, points
in sorted(grouped.items())],
20280 "output_path": os.path.abspath(output_path)
if output_path
else None,
20281 "fallback_output_path": fallback,
20287 @brief Render available summarize plot-series metadata.
20288 @param[in] catalog Available series catalog.
20289 @param[in] output_format Text or JSON output format.
20291 if output_format ==
"json":
20292 print(json.dumps({
"available_series": catalog}, indent=2, sort_keys=
True))
20294 print(
"\nAVAILABLE PLOT SERIES")
20296 for item
in catalog:
20297 labels =
", ".join(line[
"label"]
for line
in item[
"lines"])
20298 print(f
" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
20299 print(f
" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
20304 @brief Invoke standalone plot.gen with one normalized request over stdin.
20305 @param[in] request Versioned normalized plot request.
20307 plotgen_path = os.path.join(GENERATORS_PATH,
"plot.gen")
20308 if not os.path.isfile(plotgen_path):
20309 raise ValueError(f
"plot.gen script not found: {plotgen_path}")
20310 result = subprocess.run(
20311 [sys.executable, plotgen_path,
"--input",
"-"],
20312 input=json.dumps(request),
20314 capture_output=
True,
20318 print(result.stdout.rstrip())
20319 if result.returncode != 0:
20320 details = (result.stderr
or result.stdout
or "unknown plotting error").strip()
20321 if result.returncode == 3:
20323 raise ValueError(f
"plot.gen failed with exit code {result.returncode}: {details}")
20328 @brief Build and render a read-only health summary for a run step.
20329 @param[in] args Command-line style argument list supplied to the function.
20331 if args.step
is not None and args.step < 0:
20333 if args.snapshot_rows < 1:
20335 plot_series = getattr(args,
"plot_series",
None)
20336 list_plot_series = bool(getattr(args,
"list_plot_series",
False))
20337 plot_spectrum = getattr(args,
"plot_spectrum",
None)
20338 last_n = getattr(args,
"last_n",
None)
20339 plot_output = getattr(args,
"plot_output",
None)
20340 linear_y = bool(getattr(args,
"linear_y",
False))
20341 plot_mode = bool(plot_series
or list_plot_series
or plot_spectrum
is not None)
20342 existing_selectors = any(
20344 getattr(args,
"overview",
False),
20345 getattr(args,
"case",
False),
20346 getattr(args,
"solver",
False),
20347 getattr(args,
"monitor",
False),
20348 args.step
is not None,
20349 getattr(args,
"latest",
False),
20350 getattr(args,
"max_step",
False),
20353 if plot_mode
and existing_selectors:
20354 fail_cli_usage(
"Plot discovery and --plot cannot be combined with config or selected-step selectors.")
20355 if not plot_series
and last_n
is not None:
20357 if not plot_series
and plot_spectrum
is None and (plot_output
or linear_y):
20358 fail_cli_usage(
"--plot-output and --linear-y require --plot or --plot-spectrum.")
20359 if last_n
is not None and last_n < 1:
20361 if plot_series
and args.output_format ==
"json":
20362 fail_cli_usage(
"--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
20368 if list_plot_series:
20370 raise ValueError(
"No plottable scalar histories were found in the run logs.")
20373 if plot_spectrum
is not None:
20375 context, plot_spectrum,
True, linear_y, plot_output
20379 context, records, plot_series, last_n, linear_y, plot_output
20383 except PlotDependencyError
as exc:
20385 ERROR_CODE_DEPENDENCY_MISSING,
20387 file_path=sys.executable,
20391 except ValueError
as exc:
20393 ERROR_CODE_CFG_INVALID_VALUE,
20395 file_path=context[
"log_dir"],
20400 selected_configs = {
20402 for name
in (
"case",
"solver",
"monitor")
20403 if bool(getattr(args, name,
False))
20405 if getattr(args,
"overview",
False):
20406 selected_configs.update({
"case",
"solver",
"monitor"})
20407 explicit_health = args.step
is not None or bool(getattr(args,
"latest",
False))
or bool(getattr(args,
"max_step",
False))
20408 health_requested = explicit_health
or (
not selected_configs
and not getattr(args,
"overview",
False))
20411 combined = {
"storage": storage_state_summary(args.run_dir)}
20412 if selected_configs
or getattr(args,
"overview",
False):
20414 if getattr(args,
"overview",
False):
20416 combined[
"configuration"] = {}
20418 "case": _build_case_overview,
20419 "solver": _build_solver_overview,
20420 "monitor": _build_monitor_overview,
20422 for name
in (
"case",
"solver",
"monitor"):
20423 if name
in selected_configs:
20425 combined[
"configuration"][name] = builders[name](context)
20426 except (KeyError, TypeError, ValueError, ZeroDivisionError)
as exc:
20428 ERROR_CODE_CFG_INVALID_VALUE,
20430 file_path=context[
"config_paths"][name],
20431 message=f
"Could not summarize copied {name}.yml: {exc}",
20435 if not health_requested:
20436 combined[
"_health_requested"] =
False
20440 requested_step = args.step
20441 if requested_step
is None and getattr(args,
"latest",
False):
20442 requested_step =
None
20443 selection_mode =
"max_step" if getattr(args,
"max_step",
False)
else "latest"
20446 step=requested_step,
20447 snapshot_rows=args.snapshot_rows,
20448 selection_mode=selection_mode,
20450 if set(combined) == {
"storage"}:
20451 combined = {**health_payload, **combined,
"_health_requested":
True}
20454 combined = {**health_payload, **combined,
"_health_requested":
True}
20460 @brief Resolve a run/study submission target from explicit directory flags.
20461 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
20462 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
20463 @return Value returned by `_resolve_submission_target()`.
20465 has_run_dir = bool(run_dir)
20466 has_study_dir = bool(study_dir)
20467 if has_run_dir == has_study_dir:
20468 fail_cli_usage(
"submit requires exactly one of --run-dir or --study-dir.")
20470 target_kind =
"run" if has_run_dir
else "study"
20471 target_key =
"run_dir" if target_kind ==
"run" else "study_dir"
20472 root_dir = os.path.abspath(run_dir
if has_run_dir
else study_dir)
20473 if not os.path.isdir(root_dir):
20475 ERROR_CODE_CFG_FILE_NOT_FOUND,
20477 file_path=root_dir,
20478 message=f
"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
20482 scheduler_dir = os.path.join(root_dir,
"scheduler")
20483 submission_path = os.path.join(scheduler_dir,
"submission.json")
20485 if not isinstance(submission_meta, dict):
20487 ERROR_CODE_CFG_FILE_NOT_FOUND,
20488 key=
"scheduler.submission",
20489 file_path=submission_path,
20490 message=
"Target directory does not contain scheduler submission metadata.",
20491 hint=
"Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
20495 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
20496 if launch_mode ==
"local" and target_kind !=
"run":
20498 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20499 key=
"scheduler.launch_mode",
20500 file_path=submission_path,
20501 message=
"Local staged submission is supported for run directories only.",
20502 hint=
"Use --run-dir for local staged execution; study submit remains Slurm-only.",
20505 if launch_mode
not in {
"slurm",
"local"}:
20507 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20508 key=
"scheduler.launch_mode",
20509 file_path=submission_path,
20510 message=f
"Target launch_mode={launch_mode or 'unknown'} is not supported.",
20511 hint=
"Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
20515 if target_kind ==
"run":
20517 "solve": os.path.join(scheduler_dir,
"solver.sbatch"),
20518 "post-process": os.path.join(scheduler_dir,
"post.sbatch"),
20520 display_label =
"Run directory"
20521 manifest_path =
None
20524 "solve": os.path.join(scheduler_dir,
"solver_array.sbatch"),
20525 "post-process": os.path.join(scheduler_dir,
"post_array.sbatch"),
20527 display_label =
"Study directory"
20528 manifest_path = os.path.join(root_dir,
"study_manifest.json")
20531 "target_kind": target_kind,
20532 "target_key": target_key,
20533 "root_dir": root_dir,
20534 "scheduler_dir": scheduler_dir,
20535 "submission_path": submission_path,
20536 "submission_meta": submission_meta,
20537 "launch_mode": launch_mode,
20538 "script_map": script_map,
20539 "display_label": display_label,
20540 "manifest_path": manifest_path,
20546 @brief Return stored metadata for one staged submission target.
20547 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
20548 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
20549 @return Value returned by `_get_submission_stage_metadata()`.
20551 submission_meta = target_context[
"submission_meta"]
20552 if target_context[
"target_kind"] ==
"run":
20553 stages = submission_meta.get(
"stages", {})
20554 if not isinstance(stages, dict):
20556 stage_meta = stages.get(stage_name)
20557 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
20559 key =
"solver_array" if stage_name ==
"solve" else "post_array"
20560 stage_meta = submission_meta.get(key)
20561 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
20566 @brief Return stage names explicitly recorded in scheduler submission metadata.
20567 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
20568 @return Value returned by `_get_recorded_submission_stages()`.
20570 submission_meta = target_context[
"submission_meta"]
20572 if target_context[
"target_kind"] ==
"run":
20573 stages = submission_meta.get(
"stages", {})
20574 if isinstance(stages, dict):
20575 for stage_name
in [
"solve",
"post-process"]:
20576 if isinstance(stages.get(stage_name), dict):
20577 recorded.append(stage_name)
20580 if isinstance(submission_meta.get(
"solver_array"), dict):
20581 recorded.append(
"solve")
20582 if isinstance(submission_meta.get(
"post_array"), dict):
20583 recorded.append(
"post-process")
20589 @brief Format a human-readable stage list for submit diagnostics.
20590 @param[in] stage_names Argument passed to `_format_stage_list()`.
20591 @return Value returned by `_format_stage_list()`.
20593 return ", ".join(stage_names)
if stage_names
else "none"
20598 @brief Build an actionable hint for requested submit stages missing from metadata.
20599 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
20600 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
20601 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
20602 @return Value returned by `_build_submit_missing_stage_hint()`.
20605 recorded_set = set(recorded_stages)
20606 selected_set = set(selected_stages)
20607 target_flag =
"--run-dir" if target_context[
"target_kind"] ==
"run" else "--study-dir"
20608 target_path = os.path.relpath(target_context[
"root_dir"])
20609 submit_prefix = f
"picurv submit {target_flag} {target_path}"
20610 solve_stage_command = (
20611 "picurv run --solve ... --no-submit"
20612 if target_context[
"target_kind"] ==
"run"
20613 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
20615 post_stage_command = (
20616 "picurv run --post-process --post <post.yml> ... --no-submit"
20617 if target_context[
"target_kind"] ==
"run"
20618 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
20620 solve_post_command = (
20621 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
20622 if target_context[
"target_kind"] ==
"run"
20623 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
20626 if requested_stage ==
"all":
20627 if recorded_set == {
"solve"}:
20629 "--stage all requests solve and post-process, but this target records only solve. "
20630 f
"Use `{submit_prefix} --stage solve`, or re-stage with post-processing enabled "
20631 f
"(`{solve_post_command}`)."
20633 if recorded_set == {
"post-process"}:
20635 "--stage all requests solve and post-process, but this target records only post-process. "
20636 f
"Use `{submit_prefix} --stage post-process`, or re-stage including the solve stage "
20637 f
"(`{solve_stage_command}`)."
20639 missing = [stage
for stage
in selected_stages
if stage
not in recorded_set]
20642 "--stage all requests solve and post-process, but submission metadata records "
20643 f
"{_format_stage_list(recorded_stages)}. Re-stage the missing stage(s): "
20644 f
"{_format_stage_list(missing)}."
20647 if selected_set == {
"solve"}
and "solve" not in recorded_set:
20649 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
20650 f
"Re-stage with `{solve_stage_command}`."
20652 if selected_set == {
"post-process"}
and "post-process" not in recorded_set:
20654 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
20655 f
"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
20658 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
20663 @brief Persist one stage's metadata back into the submission payload.
20664 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
20665 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
20666 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
20668 submission_meta = target_context[
"submission_meta"]
20669 if target_context[
"target_kind"] ==
"run":
20670 stages = submission_meta.get(
"stages")
20671 if not isinstance(stages, dict):
20673 submission_meta[
"stages"] = stages
20674 stages[stage_name] = stage_meta
20677 key =
"solver_array" if stage_name ==
"solve" else "post_array"
20678 submission_meta[key] = stage_meta
20683 @brief Write updated submission metadata back to disk.
20684 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
20686 write_json_file(target_context[
"submission_path"], target_context[
"submission_meta"])
20688 manifest_path = target_context.get(
"manifest_path")
20689 if manifest_path
and os.path.isfile(manifest_path):
20691 if isinstance(manifest_payload, dict):
20692 manifest_payload[
"submission"] = target_context[
"submission_meta"]
20698 @brief Read run-owned directory values from a staged control file.
20700 @details Tokenizes with `shlex` because PETSc's options-file parser treats a
20701 double-quoted span as a single token. Splitting on whitespace would read
20702 `-log_dir "/tmp/VICTIM DIR"` as the value `"/tmp/VICTIM`, which looks
20703 relative and contained while PETSc would use the absolute path.
20705 Malformed quoting is reported rather than skipped: a line the parser
20706 cannot interpret is exactly the case where preflight must not assume the
20708 @param[in] control_path Path to a generated `.control` file.
20709 @return Tuple of (values, parse_errors).
20713 "-output_dir":
"output",
20714 "-restart_dir":
"restart",
20715 "-analysis_dir":
"analysis",
20718 parse_errors: list = []
20721 except OSError
as exc:
20722 return values, [f
"could not be read ({exc})"]
20723 for number, line
in enumerate(lines, start=1):
20724 if not line.strip():
20727 tokens = shlex.split(line, comments=
True)
20728 except ValueError
as exc:
20729 parse_errors.append(
20730 f
"line {number} has malformed quoting and cannot be interpreted ({exc}); "
20731 f
"refusing to assume it is safe"
20734 if tokens
and tokens[0]
in RESERVED_INDIRECTION_FLAGS:
20735 parse_errors.append(
20736 f
"line {number} uses '{tokens[0]}', which PETSc expands itself; its contents "
20737 f
"cannot be checked here and could set a run directory. Re-stage without it"
20740 if len(tokens) >= 2
and tokens[0]
in flag_to_key:
20741 values[flag_to_key[tokens[0]]] = tokens[1]
20742 return values, parse_errors
20747 @brief Every config directory under a run or study root that may hold a control file.
20749 @details A study keeps its controls under `cases/<member>/config`, not at the study
20750 root, so a preflight that only looked at `<root>/config` was empty for every
20751 study. This walks the tree so members and nested runs are covered.
20752 @param[in] root_dir Run or study directory.
20753 @return Sorted config directory paths.
20756 root = os.path.abspath(root_dir)
20757 direct = os.path.join(root,
"config")
20758 if os.path.isdir(direct):
20760 for current, dirnames, _
in os.walk(root):
20761 dirnames[:] = [d
for d
in dirnames
if not os.path.islink(os.path.join(current, d))]
20762 if os.path.basename(current) ==
"config" and glob.glob(os.path.join(current,
"*.control")):
20764 return sorted(found)
20769 @brief Re-check run-directory safety against an already-staged run or study.
20771 @details Staging validates the configuration it is given, but a staged run can be
20772 edited, or produced by an older version, before submission. This re-reads the
20773 effective staged control files - across study members and nested runs - and
20774 applies the same rules as configuration validation, plus a physical
20775 containment check that a symlink cannot slip past.
20776 @param[in] root_dir Run or study directory being submitted.
20777 @return Tuple of (errors, warnings).
20780 warnings: list = []
20782 if not config_dirs:
20783 return errors, warnings
20785 "log": CANONICAL_RUN_PATHS[
"logs"],
20786 "output": CANONICAL_RUN_PATHS[
"output"],
20787 "restart": CANONICAL_RUN_PATHS[
"restart"],
20788 "analysis": CANONICAL_RUN_PATHS[
"metrics"],
20790 for config_dir
in config_dirs:
20791 run_root = os.path.dirname(config_dir)
20792 for control
in sorted(glob.glob(os.path.join(config_dir,
"*.control"))):
20794 label = os.path.relpath(control)
20795 errors.extend(f
" {label}: {message}" for message
in parse_errors)
20798 for key, expected
in canonical.items():
20799 actual = staged.get(key)
20802 f
" {label}: -{key}_dir is {actual!r}; the canonical value is "
20803 f
"{expected!r}. Re-stage the run instead of editing its path flags."
20807 effective,
False, explicit=set(staged)
20809 errors.extend(f
" {label}: {message}" for message
in control_errors)
20810 warnings.extend(f
" {label}: {message}" for message
in control_warnings)
20812 errors.append(f
" {label}: {message}")
20813 return errors, warnings
20818 @brief Read a text file into a list of lines.
20819 @param[in] path File to read.
20820 @return List of lines.
20822 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as handle:
20823 return handle.readlines()
20828 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
20829 @param[in] args Command-line style argument list supplied to the function.
20832 run_dir=getattr(args,
"run_dir",
None),
20833 study_dir=getattr(args,
"study_dir",
None),
20836 require_storage_payload_local(target_context[
"root_dir"],
"submission")
20837 except StorageError
as exc:
20838 print(f
"[FATAL] {exc}", file=sys.stderr)
20841 target_context[
"root_dir"]
20843 for warning
in preflight_warnings:
20844 print(f
"[WARN] {warning.strip()}", file=sys.stderr)
20845 if preflight_errors:
20847 "[FATAL] Submission preflight failed: the staged run has an unsafe run-directory "
20851 for violation
in preflight_errors:
20852 print(violation, file=sys.stderr)
20854 " Re-stage the run so PICurv regenerates its canonical path flags.",
20858 if target_context[
"target_kind"] ==
"study":
20859 cold_cases = cold_study_members(target_context[
"root_dir"])
20862 "[FATAL] Submission requires cold-storage study member(s): " +
", ".join(cold_cases),
20866 stage_order = [
"solve",
"post-process"]
20867 requested_stage = args.stage
20868 selected_stages = stage_order
if requested_stage ==
"all" else [requested_stage]
20870 print(f
"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
20871 print(f
"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
20872 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
20874 if target_context.get(
"launch_mode") ==
"local":
20880 solve_existing_job_id = str(solve_existing_meta.get(
"job_id",
"")).strip()
20882 for stage_name
in selected_stages:
20884 script_path = target_context[
"script_map"][stage_name]
20886 if not existing_meta:
20888 ERROR_CODE_CFG_MISSING_KEY,
20889 key=f
"scheduler.{stage_name}.metadata",
20890 file_path=target_context[
"submission_path"],
20891 message=f
"Submission metadata does not record stage '{stage_name}'.",
20892 hint=missing_stage_hint,
20896 if not os.path.isfile(script_path):
20898 ERROR_CODE_CFG_FILE_NOT_FOUND,
20899 key=f
"scheduler.{stage_name}.script",
20900 file_path=script_path,
20901 message=f
"Required {stage_name} sbatch artifact is missing.",
20902 hint=missing_stage_hint,
20906 if existing_meta.get(
"submitted")
and not args.force:
20908 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20909 key=f
"scheduler.{stage_name}.submitted",
20910 file_path=target_context[
"submission_path"],
20911 message=f
"Stage '{stage_name}' is already recorded as submitted.",
20912 hint=
"Use --force to resubmit this stage intentionally.",
20917 if stage_name ==
"post-process":
20918 if "solve" in selected_stages:
20919 dependency =
"__NEW_SOLVE_JOB_ID__"
20921 if not (solve_existing_meta.get(
"submitted")
and solve_existing_job_id):
20923 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20924 key=
"scheduler.post-process.dependency",
20925 file_path=target_context[
"submission_path"],
20926 message=
"Post-process submission requires a recorded solve job id when solve is not being submitted in the same command.",
20927 hint=
"Submit --stage solve or --stage all first, or use --force only after solve metadata exists.",
20930 dependency = solve_existing_job_id
20932 stage_plans.append(
20934 "stage": stage_name,
20935 "script": script_path,
20936 "dependency": dependency,
20937 "existing_meta": existing_meta,
20942 for plan
in stage_plans:
20944 dependency = plan[
"dependency"]
20945 if dependency ==
"__NEW_SOLVE_JOB_ID__":
20946 cmd.append(
"--dependency=afterok:<new solve job id>")
20948 cmd.append(f
"--dependency=afterok:{dependency}")
20949 cmd.append(plan[
"script"])
20950 print(f
"[DRY-RUN] Would run: {' '.join(cmd)}")
20951 print(
"[INFO] Dry-run only. No jobs were submitted.")
20954 latest_solve_job_id =
None
20955 for plan
in stage_plans:
20956 dependency = plan[
"dependency"]
20957 if dependency ==
"__NEW_SOLVE_JOB_ID__":
20958 dependency = latest_solve_job_id
20960 submit_info =
submit_sbatch(plan[
"script"], dependency=dependency)
20961 stage_meta = copy.deepcopy(plan[
"existing_meta"])
20962 stage_meta.update(submit_info)
20963 stage_meta[
"script"] = plan[
"script"]
20964 stage_meta[
"submitted"] =
True
20966 stage_meta[
"dependency"] = f
"afterok:{dependency}"
20968 stage_meta.pop(
"dependency",
None)
20971 print(f
"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
20973 if plan[
"stage"] ==
"solve":
20974 latest_solve_job_id = submit_info[
"job_id"]
20981 @brief Execute previously staged local run commands from scheduler/submission.json.
20982 @param[in] args Command-line style argument list supplied to the function.
20983 @param[in] target_context Resolved submission target context.
20984 @param[in] selected_stages Ordered stage names selected by the user.
20986 if target_context[
"target_kind"] !=
"run":
20988 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20989 key=
"scheduler.launch_mode",
20990 file_path=target_context[
"submission_path"],
20991 message=
"Local staged execution is supported for run directories only.",
20992 hint=
"Use --run-dir for local staged execution.",
20998 solve_already_done = bool(solve_existing_meta.get(
"submitted")
or solve_existing_meta.get(
"executed"))
21000 for stage_name
in selected_stages:
21002 command = existing_meta.get(
"command")
21003 if not isinstance(command, list)
or not command:
21005 hint =
"Re-stage the run with picurv run --no-submit before calling picurv submit."
21009 ERROR_CODE_CFG_MISSING_KEY,
21010 key=f
"scheduler.{stage_name}.command",
21011 file_path=target_context[
"submission_path"],
21012 message=f
"Required local command metadata for stage '{stage_name}' is missing.",
21017 if existing_meta.get(
"submitted")
and not args.force:
21019 ERROR_CODE_CFG_INCONSISTENT_COMBO,
21020 key=f
"scheduler.{stage_name}.submitted",
21021 file_path=target_context[
"submission_path"],
21022 message=f
"Stage '{stage_name}' is already recorded as submitted.",
21023 hint=
"Use --force to execute this stage again intentionally.",
21027 if stage_name ==
"post-process" and "solve" not in selected_stages
and not args.force
and not solve_already_done:
21029 ERROR_CODE_CFG_INCONSISTENT_COMBO,
21030 key=
"scheduler.post-process.dependency",
21031 file_path=target_context[
"submission_path"],
21032 message=
"Post-process local execution requires a recorded completed solve stage when solve is not being executed in the same command.",
21033 hint=
"Submit --stage solve or --stage all first, or use --force after confirming source data exists.",
21037 log_file = existing_meta.get(
"log_file")
21038 if not isinstance(log_file, str)
or not log_file.strip():
21039 log_file = os.path.join(
"scheduler", f
"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
21041 stage_plans.append(
21043 "stage": stage_name,
21044 "command": [str(token)
for token
in command],
21045 "log_file": log_file,
21046 "existing_meta": existing_meta,
21051 for plan
in stage_plans:
21052 print(f
"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
21053 print(f
"[DRY-RUN] Log file : {plan['log_file']}")
21054 print(
"[INFO] Dry-run only. No local commands were executed.")
21058 monitor_path = os.path.join(target_context[
"root_dir"],
"config",
"monitor.yml")
21059 if os.path.isfile(monitor_path):
21062 for plan
in stage_plans:
21063 if plan[
"stage"] ==
"solve":
21065 with runtime_stage_lock(target_context[
"root_dir"],
"solver"):
21066 execute_command(plan[
"command"], target_context[
"root_dir"], plan[
"log_file"], monitor_cfg)
21067 except StorageError
as exc:
21068 print(f
"[FATAL] {exc}", file=sys.stderr)
21071 execute_command(plan[
"command"], target_context[
"root_dir"], plan[
"log_file"], monitor_cfg)
21072 stage_meta = copy.deepcopy(plan[
"existing_meta"])
21073 stage_meta[
"command"] = plan[
"command"]
21075 stage_meta[
"log_file"] = plan[
"log_file"]
21076 stage_meta[
"submitted"] =
True
21077 stage_meta[
"executed"] =
True
21078 stage_meta[
"completed_at"] = datetime.now().isoformat()
21080 print(f
"[SUCCESS] Executed local {plan['stage']} stage.")
21087 @brief Cancel Slurm-submitted jobs for an existing run directory.
21088 @param[in] args Command-line style argument list supplied to the function.
21090 run_dir = os.path.abspath(args.run_dir)
21091 if not os.path.isdir(run_dir):
21093 ERROR_CODE_CFG_FILE_NOT_FOUND,
21096 message=
"Run directory not found.",
21100 submission_path = os.path.join(run_dir,
"scheduler",
"submission.json")
21102 if not isinstance(submission_meta, dict):
21104 ERROR_CODE_CFG_FILE_NOT_FOUND,
21105 key=
"scheduler.submission",
21106 file_path=submission_path,
21107 message=
"Run directory does not contain scheduler submission metadata.",
21108 hint=
"Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
21112 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
21113 if launch_mode !=
"slurm":
21115 ERROR_CODE_CFG_INCONSISTENT_COMBO,
21116 key=
"scheduler.launch_mode",
21117 file_path=submission_path,
21118 message=f
"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
21119 hint=
"picurv cancel currently supports Slurm-submitted runs only.",
21123 stage_order = [
"solve",
"post-process"]
21124 requested_stage = args.stage
21125 selected_stages = stage_order
if requested_stage ==
"all" else [requested_stage]
21126 recorded_stages = submission_meta.get(
"stages", {})
21127 if not isinstance(recorded_stages, dict):
21128 recorded_stages = {}
21132 for stage_name
in selected_stages:
21133 stage_meta = recorded_stages.get(stage_name)
21134 if not isinstance(stage_meta, dict):
21135 skipped.append((stage_name,
"no stage metadata recorded"))
21138 job_id = str(stage_meta.get(
"job_id",
"")).strip()
21139 if not stage_meta.get(
"submitted"):
21140 skipped.append((stage_name,
"job was generated but not submitted"))
21143 skipped.append((stage_name,
"submitted stage is missing a recorded job id"))
21146 job_to_stages.setdefault(job_id, []).append(stage_name)
21148 if not job_to_stages:
21149 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
21150 print(f
"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
21151 for stage_name, reason
in skipped:
21152 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
21153 print(
"[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
21156 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
21157 print(f
"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
21158 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
21161 for stage_name, reason
in skipped:
21162 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
21164 graceful = bool(getattr(args,
"graceful",
False))
21166 for job_id, stage_names
in job_to_stages.items():
21167 joined_stage_names =
", ".join(stage_names)
21168 use_graceful_signal = graceful
and "solve" in stage_names
21169 scancel_cmd = [
"scancel"]
21170 if use_graceful_signal:
21175 scancel_cmd.extend([
"--signal=USR1",
"--full"])
21176 scancel_cmd.append(job_id)
21179 print(f
"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
21182 result = subprocess.run(scancel_cmd, text=
True, capture_output=
True, check=
False)
21183 stderr_text = (result.stderr
or "").strip()
21184 stdout_text = (result.stdout
or "").strip()
21185 if result.returncode == 0:
21186 if use_graceful_signal:
21188 f
"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
21189 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
21192 print(f
"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
21195 detail = stderr_text
or stdout_text
or "unknown scancel failure"
21196 failures.append((job_id, joined_stage_names, detail, result.returncode))
21198 f
"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
21203 print(
"[INFO] Dry-run only. No jobs were canceled.")
21212 @brief Infer the owned workspace role of one copied YAML file.
21213 @param[in] path YAML file copied from an example template.
21214 @return One of case, solver, monitor, post, cluster, study, or None.
21218 except (OSError, ValueError):
21220 keys = set(payload)
21221 if {
"grid",
"properties",
"run_control"} <= keys:
21223 if "base_configs" in keys
and (
"study_type" in keys
or "parameters" in keys
or "parameter_sets" in keys):
21225 if "scheduler" in keys
and "resources" in keys:
21227 if "source_data" in keys
or "eulerian_pipeline" in keys
or "lagrangian_pipeline" in keys:
21229 if "io" in keys
and (
"logging" in keys
or "profiling" in keys
or "diagnostics" in keys):
21231 if "momentum_solver" in keys
or "poisson_solver" in keys
or "operation_mode" in keys:
21238 @brief Rewrite copied template path scalars to workspace-root-relative homes.
21239 @param[in] value YAML subtree to rewrite.
21240 @param[in] replacements Old relative/basename paths mapped to new workspace paths.
21241 @return Rewritten YAML subtree.
21243 if isinstance(value, dict):
21245 if isinstance(value, list):
21247 if not isinstance(value, str):
21249 normalized = value.replace(
"\\",
"/").lstrip(
"./")
21250 return replacements.get(normalized, replacements.get(os.path.basename(normalized), value))
21255 @brief Select the canonical role file from a template that may carry variants.
21256 @param[in] candidates Candidate absolute YAML paths.
21257 @param[in] role Config role being selected.
21258 @param[in] template_name Source example directory name.
21259 @return Selected absolute path or None.
21264 "case": (
"case.yml", f
"{template_name}.yml",
"master_case.yml"),
21265 "solver": (
"solver.yml",
"Imp-MG-Standard.yml",
"master_solver.yml"),
21266 "monitor": (
"monitor.yml",
"Standard_Output.yml",
"master_monitor.yml"),
21267 "post": (
"post.yml",
"standard_analysis.yml",
"master_postprocessor.yml"),
21268 "cluster": (
"cluster.yml",
"slurm_cluster.yml",
"master_cluster.yml"),
21270 by_name = {os.path.basename(path): path
for path
in candidates}
21271 for name
in preferred:
21272 if name
in by_name:
21273 return by_name[name]
21274 return sorted(candidates)[0]
21278 source_template_root: str =
None) -> dict:
21280 @brief Convert a copied example into the canonical editable workspace layout.
21281 @param[in] workspace_root Newly copied workspace root.
21282 @param[in] template_name Example template identity.
21283 @param[in] source_template_root Optional original template root used to vendor references.
21284 @return Mapping of canonical config roles and relocated imported inputs.
21286 workspace_root = os.path.abspath(workspace_root)
21289 str(path)
for path
in Path(workspace_root).rglob(
"*.yml")
21290 if path.name
not in {RUNTIME_EXECUTION_EXAMPLE_FILENAME, RUNTIME_EXECUTION_CONFIG_FILENAME}
21291 and "runs" not in path.parts
and "studies" not in path.parts
21294 str(path)
for path
in Path(workspace_root).rglob(
"*.yaml")
21295 if path.name
not in {RUNTIME_EXECUTION_EXAMPLE_FILENAME, RUNTIME_EXECUTION_CONFIG_FILENAME}
21296 and "runs" not in path.parts
and "studies" not in path.parts
21298 vendored_replacements = {}
21299 if source_template_root:
21300 source_template_root = os.path.abspath(source_template_root)
21302 def vendor_references(value, source_yaml: str, dotted: str =
""):
21304 @brief Vendor referenced generator inputs into canonical workspace homes.
21305 @param[in] value YAML subtree to inspect.
21306 @param[in] source_yaml Original template YAML path.
21307 @param[in] dotted Current dotted key path.
21310 if isinstance(value, dict):
21311 for key, child
in value.items():
21312 vendor_references(child, source_yaml, f
"{dotted}.{key}" if dotted
else str(key))
21314 if isinstance(value, list):
21315 for index, child
in enumerate(value):
21316 vendor_references(child, source_yaml, f
"{dotted}[{index}]")
21318 key = dotted.rsplit(
".", 1)[-1]
21319 if key
not in _VENDORABLE_CONFIG_REFERENCE_KEYS
or not isinstance(value, str)
or not value.strip():
21321 origin = os.path.abspath(os.path.join(os.path.dirname(source_yaml), value))
21322 if not os.path.isfile(origin):
21324 if key ==
"script":
21325 home =
"config/generators"
21326 elif "grid" in dotted:
21327 home =
"config/grids"
21328 elif "initial_condition" in dotted:
21329 home =
"config/initial_conditions"
21330 elif "inlet" in dotted
or "boundary_conditions" in dotted:
21331 home =
"config/inlet_profiles"
21334 destination = os.path.join(workspace_root, *home.split(
"/"), os.path.basename(origin))
21335 os.makedirs(os.path.dirname(destination), exist_ok=
True)
21336 if not os.path.exists(destination):
21337 shutil.copy2(origin, destination)
21338 relative = os.path.relpath(destination, workspace_root).replace(os.sep,
"/")
21339 vendored_replacements[value.replace(
"\\",
"/").lstrip(
"./")] = relative
21340 vendored_replacements[os.path.basename(value)] = relative
21342 for copied_yaml
in sorted(set(yaml_files)):
21343 relative = os.path.relpath(copied_yaml, workspace_root)
21344 source_yaml = os.path.join(source_template_root, relative)
21345 if not os.path.isfile(source_yaml):
21349 except (OSError, ValueError):
21352 for path
in sorted(set(yaml_files)):
21355 roles.setdefault(role, []).append(path)
21359 for role, paths
in roles.items()
if role !=
"study"
21363 for role, paths
in roles.items():
21364 for source
in paths:
21365 if role ==
"study":
21366 relative = os.path.join(
"config",
"studies", os.path.basename(source))
21367 elif source == selected.get(role):
21368 relative = os.path.join(
"config", f
"{role}.yml")
21370 relative = os.path.join(
"config", os.path.basename(source))
21371 destination = os.path.join(workspace_root, relative)
21372 if os.path.abspath(source) == os.path.abspath(destination):
21373 destinations[source] = relative.replace(os.sep,
"/")
21374 occupied.add(os.path.abspath(destination))
21376 if os.path.abspath(destination)
in occupied
or os.path.exists(destination):
21377 original_relative = os.path.relpath(source, workspace_root)
21378 relative = os.path.join(
"config",
"variants", original_relative)
21379 destination = os.path.join(workspace_root, relative)
21381 while os.path.abspath(destination)
in occupied
or os.path.exists(destination):
21382 stem, suffix = os.path.splitext(relative)
21383 destination = os.path.join(workspace_root, f
"{stem}-{counter}{suffix}")
21385 os.makedirs(os.path.dirname(destination), exist_ok=
True)
21386 shutil.move(source, destination)
21387 destinations[source] = relative.replace(os.sep,
"/")
21388 occupied.add(os.path.abspath(destination))
21390 input_extensions = {
21391 ".picgrid":
"inputs/grids",
21392 ".vts":
"inputs/grids",
21393 ".picslice":
"inputs/inlet_profiles",
21394 ".dat":
"inputs/initial_conditions",
21397 for path
in sorted(Path(workspace_root).rglob(
"*")):
21398 if not path.is_file()
or path.suffix.lower()
not in input_extensions:
21400 if any(part
in _WORKSPACE_MANAGED_PATHS
for part
in path.relative_to(workspace_root).parts):
21402 destination_dir = os.path.join(workspace_root, *input_extensions[path.suffix.lower()].split(
"/"))
21403 destination = os.path.join(destination_dir, path.name)
21404 if os.path.exists(destination):
21406 old_relative = path.relative_to(workspace_root).as_posix()
21407 shutil.move(str(path), destination)
21408 new_relative = os.path.relpath(destination, workspace_root).replace(os.sep,
"/")
21409 input_moves[old_relative] = new_relative
21410 input_moves[path.name] = new_relative
21412 config_replacements = {}
21413 basename_counts = {}
21414 for source
in destinations:
21415 basename = os.path.basename(source)
21416 basename_counts[basename] = basename_counts.get(basename, 0) + 1
21417 for source, relative
in destinations.items():
21418 old_relative = os.path.relpath(source, workspace_root).replace(os.sep,
"/")
21419 config_replacements[old_relative] = relative
21420 if basename_counts[os.path.basename(source)] == 1:
21421 config_replacements[os.path.basename(source)] = relative
21422 replacements = {**config_replacements, **input_moves, **vendored_replacements}
21423 for path
in sorted(Path(workspace_root,
"config").rglob(
"*.yml")):
21425 local_replacements = dict(replacements)
21426 original_source = next(
21428 source
for source, relative
in destinations.items()
21429 if os.path.abspath(os.path.join(workspace_root, relative)) == os.path.abspath(path)
21433 if original_source:
21434 original_parent = os.path.dirname(original_source)
21435 for candidate_source, candidate_relative
in destinations.items():
21436 relative_reference = os.path.relpath(candidate_source, original_parent).replace(os.sep,
"/")
21437 local_replacements[relative_reference] = candidate_relative
21438 if os.path.dirname(candidate_source) == original_parent:
21439 local_replacements[os.path.basename(candidate_source)] = candidate_relative
21442 if role ==
"monitor" and isinstance(rewritten.get(
"io"), dict):
21443 rewritten[
"io"].pop(
"directories",
None)
21444 if role ==
"case" and path == Path(workspace_root,
"config",
"case.yml"):
21445 rewritten.setdefault(
"title", template_name)
21447 if isinstance(rewritten.get(
"source_data"), dict):
21448 rewritten[
"source_data"].pop(
"directory",
None)
21449 if isinstance(rewritten.get(
"io"), dict):
21450 rewritten[
"io"].pop(
"output_directory",
None)
21451 if role ==
"study" and isinstance(rewritten.get(
"base_configs"), dict):
21452 for role
in (
"case",
"solver",
"monitor",
"post"):
21453 canonical = os.path.join(
"config", f
"{role}.yml").replace(os.sep,
"/")
21454 if os.path.isfile(os.path.join(workspace_root, canonical)):
21455 rewritten[
"base_configs"][role] = canonical
21460 "canonical_roles": {
21461 role: os.path.join(workspace_root,
"config", f
"{role}.yml")
21462 for role
in (
"case",
"solver",
"monitor",
"post",
"cluster")
21463 if os.path.isfile(os.path.join(workspace_root,
"config", f
"{role}.yml"))
21465 "input_moves": input_moves,
21469WORKSPACE_INPUT_DIRECTORIES = {
21470 "grid":
"inputs/grids",
21471 "initial-condition":
"inputs/initial_conditions",
21472 "inlet-profile":
"inputs/inlet_profiles",
21473 "reference-field":
"inputs/reference_fields",
21478 name: str =
None, mode: str =
"copy") -> dict:
21480 @brief Explicitly import or register one workspace input.
21481 @param[in] workspace_root Initialized workspace root.
21482 @param[in] kind Semantic input kind.
21483 @param[in] source Existing source file path.
21484 @param[in] name Optional destination basename.
21485 @param[in] mode Copy, reflink, hardlink, or external-reference mode.
21486 @return Catalog entry describing the durable input identity.
21488 workspace_root = os.path.abspath(workspace_root)
21491 if kind
not in WORKSPACE_INPUT_DIRECTORIES:
21492 raise ValueError(f
"Unsupported input kind: {kind}")
21493 source = os.path.abspath(os.path.expanduser(source))
21494 if not os.path.isfile(source):
21495 raise ValueError(f
"Input source is not a file: {source}")
21496 basename = name
or os.path.basename(source)
21497 if basename != os.path.basename(basename)
or basename
in _PLAIN_FILENAME_SENTINELS:
21498 raise ValueError(
"--name must be a plain filename without directory traversal.")
21499 target_dir = os.path.join(workspace_root, *WORKSPACE_INPUT_DIRECTORIES[kind].split(
"/"))
21500 os.makedirs(target_dir, exist_ok=
True)
21501 if mode ==
"reference":
21502 destination = os.path.join(target_dir, basename +
".reference.yml")
21504 "schema_version": 1,
21505 "picurv_external_reference": source,
21507 "bytes_at_registration": os.path.getsize(source),
21510 destination = os.path.join(target_dir, basename)
21511 if os.path.exists(destination):
21512 raise ValueError(f
"Workspace input already exists: {destination}")
21513 temporary = f
"{destination}.tmp.{os.getpid()}"
21516 shutil.copy2(source, temporary)
21517 elif mode ==
"hardlink":
21518 os.link(source, temporary)
21519 elif mode ==
"reflink":
21520 cp = shutil.which(
"cp")
21522 raise ValueError(
"reflink mode requires the 'cp' command.")
21523 result = subprocess.run(
21524 [cp,
"--reflink=always",
"--preserve=mode,timestamps", source, temporary],
21525 text=
True, capture_output=
True, check=
False,
21527 if result.returncode != 0:
21528 raise ValueError((result.stderr
or "reflink copy failed").strip())
21530 raise ValueError(f
"Unsupported import mode: {mode}")
21531 os.replace(temporary, destination)
21533 if os.path.lexists(temporary):
21534 os.remove(temporary)
21535 relative = os.path.relpath(destination, workspace_root).replace(os.sep,
"/")
21537 catalog_path = os.path.join(workspace_root,
"inputs",
"catalog.yml")
21538 catalog =
read_yaml_file(catalog_path)
if os.path.isfile(catalog_path)
else {
21539 "schema_version": 1,
"inputs": {}
21541 catalog.setdefault(
"inputs", {})[entry_id] = {
21546 "bytes": os.path.getsize(source),
21547 "source": source
if mode ==
"reference" else None,
21548 "registered_at": datetime.now().astimezone().isoformat(),
21551 return {
"id": entry_id, **catalog[
"inputs"][entry_id]}
21556 @brief Handle explicit workspace input management.
21557 @param[in] args Parsed inputs command arguments.
21560 workspace_root = os.path.abspath(args.workspace)
if args.workspace
else find_workspace_root(os.getcwd())
21561 if not workspace_root:
21562 raise ValueError(
"No initialized workspace found; run picurv init first or pass --workspace.")
21563 if args.inputs_action !=
"import":
21564 raise ValueError(f
"Unsupported inputs action: {args.inputs_action}")
21566 workspace_root, args.kind, args.source, name=args.name, mode=args.mode
21568 print(f
"[SUCCESS] Registered input {entry['id']}: {entry['path']}")
21569 if entry[
"mode"] ==
"reference":
21570 print(
"[WARNING] This is an external reference. Storage will record it but will not copy or prune its target.")
21575 @brief Report, and for the `status` action validate, the shared build identity.
21577 @details Bare `picurv version` reports and always succeeds; it is an informational
21578 surface that scripts and documentation already depend on. `picurv version
21579 status` additionally exits non-zero when the conductor, the executables,
21580 and the workspace requirement do not agree, so a job script can refuse to
21581 launch a run whose provenance would be incoherent.
21582 @param[in] args Parsed version command arguments.
21586 payload = dict(PICURV_BUILD)
21587 payload[
"source_root"] = PACKAGE_PROJECT_ROOT
21588 payload[
"workspace"] = workspace_root
21589 payload[
"workspace_requirement"] =
None
21592 payload[
"workspace_requirement"] = software.get(
"picurv")
if isinstance(software, dict)
else None
21594 validating = getattr(args,
"version_action",
None) ==
"status"
21596 payload[
"coherent"] =
not problems
21597 payload[
"problems"] = problems
21598 if getattr(args,
"output_format",
"text") ==
"json":
21599 print(json.dumps(payload, indent=2, sort_keys=
True))
21600 if validating
and problems:
21603 print(f
"PICurv release : {payload['release_version']}")
21604 print(f
"Build identity : {payload['build_id']}")
21605 print(f
"Git commit : {payload.get('git_commit') or 'unavailable'}")
21606 print(f
"Dirty tree : {payload.get('dirty') if payload.get('dirty') is not None else 'unknown'}")
21608 print(f
"Workspace : {workspace_root}")
21609 print(f
"Requirement : {payload['workspace_requirement'] or 'latest active version'}")
21610 print(
"\nNative executables")
21611 for name, identity
in sorted(payload[
"binaries"].items()):
21612 if not identity.get(
"available"):
21613 print(f
" {name:<14}: unavailable ({identity.get('reason', 'unknown')})")
21615 agreement =
"matches source" if identity[
"matches_source"]
else "STALE - rebuild"
21616 print(f
" {name:<14}: {identity['build_id']} ({agreement})")
21621 print(
"\nBuild identity is coherent.")
21623 print(
"\nBuild identity is NOT coherent:", file=sys.stderr)
21624 for problem
in problems:
21625 print(f
" - {problem}", file=sys.stderr)
21631 @brief Refuse version-changing Git operations in a dirty source checkout.
21632 @param[in] action User-facing action name.
21635 result = subprocess.run(
21636 [
"git",
"status",
"--porcelain"], cwd=PACKAGE_PROJECT_ROOT,
21637 text=
True, capture_output=
True, check=
False,
21639 if result.returncode != 0:
21640 raise ValueError(f
"{action}: cannot inspect the PICurv Git checkout.")
21641 if result.stdout.strip():
21643 f
"{action}: the PICurv source checkout has uncommitted changes. "
21644 "Commit or stash them before changing versions."
21650 @brief Run a checked Git command against the active PICurv source checkout.
21651 @param[in] arguments Git arguments excluding the executable.
21652 @return Completed successful Git process.
21654 result = subprocess.run(
21655 [
"git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
21656 text=
True, capture_output=
True, check=
False,
21658 if result.returncode != 0:
21659 raise ValueError((result.stderr
or result.stdout
or "Git command failed.").strip())
21665 @brief Fetch source history without silently changing the active code.
21666 @param[in] args Parsed source command arguments.
21669 if args.source_action !=
"update":
21670 raise ValueError(f
"Unsupported source action: {args.source_action}")
21672 print(f
"[SUCCESS] Fetched branches and tags from {args.remote}; active checkout was not changed.")
21677 @brief Resolve an exact version requested by an initialized workspace.
21678 @param[in] workspace_root Initialized workspace root.
21679 @return Exact version or tag text.
21682 requirement = software.get(
"picurv")
if isinstance(software, dict)
else None
21683 if not requirement:
21685 f
"{workspace_root}/{WORKSPACE_CONFIG_FILENAME} has no software.picurv pin; "
21686 "name a version explicitly."
21688 if any(token
in str(requirement)
for token
in "<>=!~,*"):
21690 "Automatic activation needs an exact release/tag, not a version range. "
21691 f
"Pass the desired version explicitly (workspace requires {requirement!r})."
21693 return str(requirement)
21698 @brief List or activate a release using the existing source/build owners.
21699 @param[in] args Parsed versions command arguments.
21702 action = args.versions_action
21703 if action ==
"list":
21705 print(f
"Active: {PICURV_BUILD['build_id']}")
21706 tags = [line
for line
in result.stdout.splitlines()
if line.strip()]
21708 print(
"Installed/available tags:")
21712 print(
"No version tags are present in this checkout.")
21714 version = getattr(args,
"version",
None)
21715 if action ==
"activate" and not version:
21716 workspace_root = os.path.abspath(args.workspace)
if args.workspace
else find_workspace_root(os.getcwd())
21717 if not workspace_root:
21718 raise ValueError(
"No workspace found and no version was named.")
21720 if action
not in _VERSION_BUILD_ACTIONS:
21721 raise ValueError(f
"Unsupported versions action: {action}")
21727 f
"[WARNING] {PACKAGE_PROJECT_ROOT} is a single shared installation. Building "
21728 f
"{version!r} here re-points every workspace that resolves executables from it, "
21729 "including any job already running against them.",
21733 " Case-local executables pinned with 'picurv init --pin-binaries' are "
21739 result = subprocess.run([
"make",
"all"], cwd=PACKAGE_PROJECT_ROOT, check=
False)
21740 if result.returncode != 0:
21741 raise ValueError(f
"Build failed after activating {version!r}.")
21742 print(f
"[SUCCESS] Activated and built PICurv {version}.")
21747 @brief Implements the 'init' command.
21748 @details Creates a new case study directory by copying a template.
21749 Runtime binaries are resolved from the project bin/ directory
21750 via PATH; pass --pin-binaries to pin specific versions locally.
21751 @param[in] args The command-line arguments parsed by argparse.
21757 except ValueError
as exc:
21758 print(f
"[FATAL] {exc}", file=sys.stderr)
21762 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name
if args.dest_name
else args.template_name))
21764 if os.path.exists(dest_path):
21765 print(f
"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
21768 print(f
"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
21770 shutil.copytree(template_path, dest_path)
21771 print(f
"[SUCCESS] Copied template files to: {dest_path}")
21773 copied_runtime_example = os.path.join(dest_path, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
21774 if os.path.isfile(copied_runtime_example):
21775 os.remove(copied_runtime_example)
21779 dest_path, args.template_name, source_template_root=template_path
21781 print(f
"[INFO] Wrote workspace identity: {os.path.relpath(workspace_layout['workspace_config'])}")
21782 if workspace_layout[
"canonical_roles"]:
21783 print(
"[INFO] Canonical editable configurations:")
21784 for role, path
in sorted(workspace_layout[
"canonical_roles"].items()):
21785 print(f
" - {role}: {os.path.relpath(path)}")
21786 except Exception
as exc:
21787 print(f
"[FATAL] Failed to create canonical workspace layout: {exc}", file=sys.stderr)
21788 shutil.rmtree(dest_path, ignore_errors=
True)
21793 print(f
"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
21794 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
21795 print(
" Seeded from repo-local '.picurv-execution.yml'.")
21796 print(
" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
21797 except Exception
as e:
21798 print(f
"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
21803 source_project_root,
21804 template_name=args.template_name,
21807 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
21810 print(f
"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
21811 except Exception
as e:
21812 print(f
"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
21814 cluster_profile_candidates = sorted(
21816 os.path.basename(path)
21817 for pattern
in (
"*cluster*.yml",
"*cluster*.yaml")
21818 for search_root
in (dest_path, os.path.join(dest_path,
"config"))
21819 for path
in glob.glob(os.path.join(search_root, pattern))
21822 if cluster_profile_candidates:
21823 print(
"[INFO] Cluster profile sample(s) copied with this case:")
21824 for profile_name
in cluster_profile_candidates:
21825 print(f
" - {profile_name}")
21826 print(
" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
21828 if getattr(args,
"pin_binaries",
False):
21829 print(
"[INFO] Pinning runtime binaries into case directory...")
21832 for dest_file_path
in copied_binaries:
21833 print(f
" - Pinned '{os.path.basename(dest_file_path)}'")
21834 print(
"[SUCCESS] Case directory is ready with pinned binaries.")
21835 print(
" These local copies will be used instead of bin/ originals.")
21836 except ValueError
as exc:
21837 print(f
"[WARNING] {exc}", file=sys.stderr)
21838 print(
" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
21840 print(
"[SUCCESS] Case directory is ready.")
21841 print(
" Runtime binaries (simulator, postprocessor) are resolved from the active PICurv installation.")
21842 print(
" Pin software.picurv in .picurv-workspace.yml only when this workspace needs a release constraint.")
21843 print(
" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
21848 @brief Refresh template-managed config/docs files in a case directory.
21849 @param[in] args Command-line style argument list supplied to the function.
21853 case_dir_hint=getattr(args,
"case_dir",
None),
21854 source_root_override=getattr(args,
"source_root",
None),
21855 template_name_override=getattr(args,
"template_name",
None),
21859 template_name = context.get(
"template_name")
21861 existing_managed = context.get(
"metadata", {}).get(
"template_managed_files")
21862 if not isinstance(existing_managed, list):
21863 existing_managed =
None
21867 overwrite=getattr(args,
"overwrite",
False),
21868 prune=getattr(args,
"prune",
False),
21869 managed_rel_paths=existing_managed,
21873 source_project_root,
21874 template_name=template_name,
21875 existing=context.get(
"metadata"),
21876 template_managed_files=summary[
"template_managed_files"],
21879 except ValueError
as exc:
21880 print(f
"[FATAL] {exc}", file=sys.stderr)
21883 print(f
"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
21884 print(f
"[INFO] Copied new files : {len(summary['copied'])}")
21885 print(f
"[INFO] Overwritten files : {len(summary['overwritten'])}")
21886 print(f
"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
21887 print(f
"[INFO] Already unchanged : {len(summary['unchanged'])}")
21888 print(f
"[INFO] Pruned stale files : {len(summary['pruned'])}")
21889 if runtime_result[
"created"]:
21890 print(f
"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
21891 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
21892 print(
"[INFO] Seed source : repo-local .picurv-execution.yml")
21893 if summary.get(
"prune_requested_without_tracking"):
21894 print(
"[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
21895 print(f
"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
21900 @brief Refresh source branches in the repository resolved from a case directory.
21901 @param[in] args Command-line style argument list supplied to the function.
21905 case_dir_hint=getattr(args,
"case_dir",
None),
21906 source_root_override=getattr(args,
"source_root",
None),
21909 except ValueError
as exc:
21910 print(f
"[FATAL] {exc}", file=sys.stderr)
21913 rebase =
not getattr(args,
"no_rebase",
False)
21914 remote = getattr(args,
"remote",
None)
21915 branch = getattr(args,
"branch",
None)
21916 current_branch_only = (
21917 getattr(args,
"current_branch_only",
False)
21918 or remote
is not None
21919 or branch
is not None
21922 if not current_branch_only:
21926 command = [
"git",
"pull"]
21928 command.append(
"--rebase")
21930 command.append(remote)
21932 command.append(branch)
21934 command.extend([
"origin", branch])
21940 @brief Implements the 'build' command.
21941 @details Executes the top-level Makefile directly, passing through any
21942 additional arguments to `make`. This allows for building,
21943 cleaning, and other Makefile targets via the orchestrator
21944 without maintaining a separate build wrapper script.
21945 @param[in] args The command-line arguments parsed by argparse.
21948 print(
"\n" +
"="*27 +
" BUILD STAGE " +
"="*27)
21951 case_dir_hint=getattr(args,
"case_dir",
None),
21952 source_root_override=getattr(args,
"source_root",
None),
21955 except ValueError
as exc:
21956 print(f
"[FATAL] {exc}", file=sys.stderr)
21959 makefile_path = os.path.join(source_project_root,
"Makefile")
21961 if not os.path.isfile(makefile_path):
21962 print(f
"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
21963 print(
" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
21966 make_args =
list(args.make_args
or [])
21968 command = [
"make"] + make_args
21970 command = [
"make",
"all"] + make_args
21971 print(
"[INFO] No explicit make target supplied; defaulting to 'all'.")
21972 print(
" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
Raised when an external command exits unsuccessfully.
__init__(self, list command, int returncode, str details=None)
Initialize a command execution error.
Raised when plot.gen reports a missing optional dependency.
Module-like proxy that preserves picurv.np without eager import.
__getattr__(self, name)
Resolve a NumPy attribute on first use.
summarize_workflow(args)
Build and render a read-only health summary for a run step.
str get_monitor_output_directory(dict monitor_cfg, str default="output")
Resolve the solver output root from monitor.yml, preserving the default layout.
dict _summarize_turbulence(dict turbulence_cfg)
Build compact turbulence and wall-model selections.
set resolve_post_stage_selection(only)
Resolve the --only selector into the set of post stages to execute.
dict translate_programmatic_grid_settings(dict grid_settings)
Return programmatic-grid settings translated to the C node-count contract.
tuple validate_run_directory_containment(dict monitor_cfg, str monitor_path)
Classify legacy directory values as defense-in-depth during validation.
find_runtime_execution_config_file(*anchors)
Find the nearest optional execution config from runtime/case anchors.
int normalize_particle_init_mode(str value)
Maps canonical particle init mode names to C enum/int codes (-pinit).
_write_submission_target_metadata(dict target_context)
Write updated submission metadata back to disk.
status_source_command(args)
Report source/case drift for an initialized case directory.
_render_monitor_summary_text(dict summary)
Render the monitor summary as a glanceable observability dashboard.
validate_workflow(args)
Implements picurv validate without launching solver/post workflows.
dict _normalize_square_duct_poiseuille_params(params, str field_name)
Validate square-duct Poiseuille generator parameters.
str _capture_command_stdout(list command, str run_dir)
Run a command, require success, and return stripped stdout text.
list expand_parameter_matrix(dict parameters)
Expand study parameter lists into cartesian-product combinations.
_render_case_summary_text(dict summary)
Render the case summary as a glanceable simulation dashboard.
_parse_float_loose(value)
Best-effort float parsing for summary extraction.
render_run_summary(dict payload, str output_format="text")
Render a run-step summary in human or JSON form.
control_value(value, str context)
Guard a value that is written verbatim into the generated control file.
int normalize_les_model(value)
Maps LES model selectors to C enum/int codes (-les).
list read_text_file_lines(str path)
Read a text file into a list of lines.
bool needs_restart_source(dict case_cfg, dict solver_cfg)
Return True when the solver requires restart data from disk.
str resolve_target_grid_for_generated_profile(dict case_cfg, str case_path, str run_dir)
Resolve an optional target canonical PICGRID for generated profile sampling.
str ensure_post_lock_wrapper(str run_dir)
Ensure the lock wrapper exists for a run directory and return its path.
populate_restart_directory(str source_output, str target_restart, int start_step, dict monitor_cfg, "int | None" end_step=None, bool materialize=True)
Atomically materialize an immutable checkpoint interval into a run.
validate_study_config(dict study_cfg, str study_path, bool skip_base_file_check=False)
Validate sweep/study specification from study.yml.
"tuple[dict, str]" compute_post_recipe_fingerprint(dict recipe_cfg)
Return normalized recipe signature plus SHA-256 fingerprint.
list_template_relative_files(str template_dir, excluded_rel_paths=None)
List all files in a template directory as case-relative paths.
int normalize_les_clip_mode(value)
Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
dict _normalize_execution_override_section(dict payload, str section_name, str config_path, str config_label)
Validate one execution override section while preserving missing-vs-empty semantics.
list _representative_indices(int count, int maximum=6)
Select evenly distributed indices while always retaining both endpoints.
dict _build_run_overview(dict context)
Build timestep-independent run metadata for summarize.
_iter_parent_dirs(str start_path)
Yield a path and all of its parents up to filesystem root.
str _workspace_asset_set_path(str workspace_root, str case_path)
Return the mutable asset-set pointer associated with a case config name.
str workspace_artifact_root(str workspace_root, str kind)
Return the canonical workspace-owned root for runs or studies.
get_post_field_statistics_artifacts(dict post_cfg, str run_dir)
Predict the per-window statistics artifacts a recipe will produce.
pull_all_source_branches(str run_dir, str log_filename, bool rebase=True)
Refresh every local tracking branch in the source repository, then restore the starting branch.
sync_case_binaries(str case_dir, str source_project_root)
Copy current source-repo binaries into a case directory for version-pinning.
dict _source_build_identity(str release_version)
Resolve reproducible release, commit, and dirty-tree build identity.
dict load_workspace_config(str workspace_root)
Load and validate the immutable workspace identity/configuration file.
list generate_multi_block_bcs(str run_dir, str run_id, dict case_cfg, dict source_files, str config_dir=None)
Parses multi-block BCs from YAML, generates a .run file for each block, and returns a list of their a...
_parse_int_loose(value)
Best-effort integer parsing for summary extraction.
str resolve_post_spectra_output_dir(monitor_cfg=None)
Resolve the run-relative directory spectra CSVs are written to.
"tuple[dict, list[int]]" _parse_profiling_timestep_csv(str filepath)
Parse profiling timestep CSV into latest rows by step plus observed order.
bool _working_tree_has_tracked_changes(str run_dir)
Return True when the repository has staged or unstaged tracked changes.
_append_summary_plot_record(list records, str source, step, str line, dict values, str source_path, int segment=0, dict coordinates=None)
Append one numeric append-ordered record for summarize plotting.
list parse_case_index_tsv(str tsv_path)
Parse a case_index.tsv file back into a list of case entry dicts.
None ensure_workspace_layout(str workspace_root)
Materialize the uniform, cheap directory skeleton for one workspace.
str normalize_statistics_task(str task_name)
Normalizes user-facing statistics task names to C pipeline keywords.
bool _post_requests_particle_output(dict post_cfg)
Return whether the current post recipe expects particle VTP output artifacts.
str _resolve_case_relative_path(str path_value, str case_dir)
Resolve a path relative to the current case directory.
str resolve_run_restart_dir(str run_dir, dict monitor_cfg)
Resolve the restart staging directory within a run directory.
extract_metric_from_csv(str case_dir, dict spec)
Extract a scalar metric from a CSV source.
list materialize_generated_prescribed_flow_profiles(str run_dir, dict case_cfg, str case_path, list profile_grid_dims=None)
Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
dict build_walltime_guard_exports("dict | None" cluster_cfg)
Build shell-evaluated environment exports for the runtime walltime guard.
dict detect_case_completion_status(str run_dir, dict monitor_cfg, int target_final_step)
Determine whether a study case is complete, partially complete, or empty.
str _checkpoint_bundle_path(str source_dir, int step)
Resolve a run/output root or an exact checkpoint bundle.
validate_wall_model_pairing(dict case_cfg, les_cfg, rans_cfg, wall_cfg, str case_path, list errors, list warnings)
Rejects wall-model selections that no turbulence treatment can support.
dict materialize_run_assets(str run_dir, dict case_cfg, str case_path, bool require_precomputed=False, bool fetch_missing=False)
Resolve/build workspace assets and write the exact run input lock.
dict ensure_case_runtime_execution_config(str case_dir, str source_project_root, bool overwrite=False)
Create case-local runtime execution config if missing, seeded from repo-local config when available.
reduce_metric_values(values, str reduction)
Reduce a metric series to one scalar according to the requested reducer.
_set_submission_stage_metadata(dict target_context, str stage_name, dict stage_meta)
Persist one stage's metadata back into the submission payload.
str run_initial_spectrum_generator(str field_path, str staged_grid, str spectrum_path, str case_dir)
Measure the shell-averaged spectrum of a staged initial condition.
_resolve_summary_step(requested_step, continuity_rows, particle_rows, momentum_rows, poisson_rows, profiling_rows, memory_rows=None, convergence_rows=None, step_orders=None, str selection_mode="latest")
Select a step to summarize from available metric artifacts.
list validate_grid_generator_cli_args(cli_args, str case_path)
Check closed-choice values inside the generator's opaque token list.
dict build_run_summary_payload(str run_dir, "int | None" step=None, int snapshot_rows=5, str selection_mode="latest")
Build a read-only run-step summary from existing PICurv artifacts.
str _face_artifact_token(str face)
Convert a BC face token into a filesystem-friendly artifact token.
str get_post_recipe_root(str run_dir, dict post_cfg)
Return the versioned run-local control directory for one post recipe.
_attempt_pull_cleanup(str run_dir, bool rebase, log_file)
Best-effort cleanup after a failed git pull so the original branch can be restored.
absolutize_case_external_paths(dict case_cfg, str case_anchor_path)
Convert external grid/generator paths in case config to absolute paths.
dict build_run_manifest(str run_dir, str run_id, *workspace_root=None, str launch_mode="local", int num_procs=1, int post_num_procs=1, stages_requested=None, stages_completed=None, inputs=None, asset_lock=None, submission=None, lineage=None, str artifact_type="run", study_id=None, case_id=None)
Build the authoritative run identity, topology, and lifecycle manifest.
list read_picgrid_header_dimensions(str source_grid, int expected_nblk=None)
Read only the canonical PICGRID header dimensions.
str write_software_lock(str run_dir)
Write the run's software lock beside its asset lock.
dict generate_square_duct_poiseuille_picslice(str output_path, tuple dims, dict params, str target_grid=None, int target_block=0, str target_face=None, str script=None, str case_path=None)
Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
write_json_file(str filepath, dict payload)
Write JSON metadata/manifests with a stable, readable format.
list _build_summary_plot_catalog(list records)
Build available qualified-series metadata from plot records.
str _build_submit_missing_stage_hint(dict target_context, str requested_stage, list selected_stages)
Build an actionable hint for requested submit stages missing from metadata.
execute_command(list command, str run_dir, str log_filename, dict monitor_cfg=None)
Executes a command, streaming its output to the console and a log file.
str classify_run_directory_value(value)
Classify a configured run directory value.
version_workflow(args)
Report, and for the status action validate, the shared build identity.
str get_post_resume_state_path(str run_dir, dict post_cfg=None)
Return the JSON resume metadata path for a run directory.
str write_profile_info(str config_dir, list summaries)
Write a profile.info summary for generated inlet profiles.
list validate_reserved_directory_flags(dict config, str config_path, str label)
Reject raw PETSc passthrough options that set run-owned directories.
compute_case_source_status(str case_dir, str source_project_root, str template_name=None, dict metadata=None)
Compute source/case drift across commits, binaries, and template-managed files.
bool make_args_include_explicit_goal("list[str]" make_args)
Return True when make args contain an explicit target rather than only options/assignments.
_stream_command_to_console_and_log(list command, str run_dir, log_file)
Stream command output to stdout and an already-open log file.
render_selected_summary(dict payload, str output_format="text")
Render selected timestep-independent config views and optional health.
str resolve_post_statistics_output_prefix(dict post_cfg, monitor_cfg=None, str default="Stats")
Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
dict normalize_post_field_statistics_config(dict post_cfg)
Validate and canonicalize the field_statistics block of post.yml.
"list[tuple[str, str | None]]" _get_local_branches_with_upstreams(str run_dir)
Return local branch names plus their configured upstreams.
bool _launcher_arg_contains_whitespace(token)
Return True when a launcher arg token contains embedded whitespace and should be split.
validate_load_mode_step_range(str source_output, int start_step, int total_steps, dict monitor_cfg)
Validate that all required eulerian step files exist for "load" mode.
sweep_reaggregate_workflow(args)
Re-run metrics aggregation and plot generation for an existing study.
_workspace_yaml_role(str path)
Infer the owned workspace role of one copied YAML file.
emit_structured_error(str code, str key="-", str file_path="-", str message="", str hint=None, stream=None)
Emit one standardized error line for tooling and users.
dict snapshot_run_configuration(str run_dir, dict source_paths, bool continuation=False)
Snapshot editable YAML inputs without erasing prior continuation state.
_read_runtime_diagnostics_csv(path)
Yields (segment, row) for each data line of a runtime diagnostics CSV.
int _post_window_derived_field_count(dict window_cfg, list outputs)
Count the derived fields one window would produce for a set of outputs.
_find_named_file_upwards(str start, str filename)
Find a named file at or above an arbitrary filesystem anchor.
str normalize_solution_convergence_mode(str value)
Normalizes the solution-convergence mode selector to the C-side canonical string.
dict _build_summary_plot_request(dict context, list records, str series, "int | None" last_n, bool linear_y, "str | None" output_path)
Build one normalized plot.gen request from collected summarize records.
str compute_post_spectra_signature(dict spectra_cfg)
Reduce a normalized spectra recipe to a stable identity string.
list classify_physical_containment(str run_dir, dict values)
Classify where each run-owned directory physically lands.
normalize_boundary_conditions_layout(all_blocks_bcs, int num_blocks)
Normalize boundary_conditions to list-of-lists form and validate block count.
dict archive_active_generated_configuration(str run_dir, list paths)
Preserve generated control sidecars beside a continuation's YAML revision.
str resolve_workspace_path(str anchor_file, str candidate, *bool allow_external=False)
Resolve a user path against its workspace and reject implicit escapes.
str normalize_momentum_solver_type(str value)
Maps canonical user-facing momentum solver names to C-enum CLI values.
_restore_git_head(str run_dir, dict original_head, log_file)
Restore the repository back to the branch or detached commit it started on.
dict _read_previous_metric_rows(str results_dir)
Read the metrics table an earlier aggregation wrote, keyed by case id.
resolve_restart_source(args, dict case_cfg, dict solver_cfg, dict monitor_cfg, str run_dir, bool materialize=True)
Resolve the restart source directory based on –restart-from or –continue CLI flags.
get_post_source_data(dict post_cfg)
Return source_data as a mapping when valid, else an empty mapping.
str _build_post_lock_wrapper_source()
Return the Python wrapper used to hold an exclusive post-stage lock.
float _resolve_field_slice_velocity_scale(dict source, str case_dir)
Resolve field_slice dimensional velocity scale.
bool paths_overlap(str first, str second)
Whether two run-relative directories are the same or nested in one another.
None append_grid_da_processor_layout(list control_lines, dict grid_cfg, int num_procs)
Append optional global DMDA layout flags for any grid mode.
dict generate_picgrid_from_programmatic_settings(dict raw_settings, str dest_path, float L_ref)
Generate a canonical PICGRID file from programmatic Cartesian grid settings.
validate_simulation_configs(dict case_cfg, dict solver_cfg, dict monitor_cfg, str case_path, str solver_path, str monitor_path)
Validates every configuration a simulation run consumes, before any work is done.
get_post_statistics_output_artifacts(dict post_cfg, str run_dir, monitor_cfg=None)
Predict statistics CSV output paths relative to the postprocessor runtime cwd.
bool _post_requests_eulerian_output(dict post_cfg)
Return whether the current post recipe expects Eulerian VTK output artifacts.
bool is_valid_email(str email)
Lightweight email validation for scheduler notifications.
"int | None" resolve_statistics_console_output_frequency(dict io_cfg)
Resolve the statistics console cadence, mirroring the particle one.
str _materialize_asset_file(str source, str destination)
Expose one immutable shared-asset file through reflink, hardlink, or copy.
list build_petsc_diagnostics_args(dict monitor_cfg, str run_dir, str stage_label)
Build PETSc diagnostics command-line arguments for a run stage.
dict _picgrid_geometry_summary(str path)
Read a canonical PICGRID and summarize what a user would want to check.
_read_yaml_if_exists(str filepath)
Read YAML when present, otherwise return None.
list warn_on_stale_runtime_binaries(dict identities)
Report native executables whose build identity is not the active source.
dict runtime_build_identities()
Read the build identity of every native executable a run would launch.
str resolve_path(str anchor_file, str candidate)
Resolve a potentially relative path against a source YAML file path.
str _post_output_directory_abs(str run_dir, dict post_cfg)
Resolve the absolute post output directory for the current recipe.
tuple _bc_profile_expected_dims(str face, tuple block_dims)
Return expected PICSLICE dimensions for a face and block node dimensions.
list preflight_config_directories(str root_dir)
Every config directory under a run or study root that may hold a control file.
None _require_clean_source_checkout(str action)
Refuse version-changing Git operations in a dirty source checkout.
_require_successful_command(list command, subprocess.CompletedProcess result)
Raise CommandExecutionError when a captured command failed.
"tuple[dict, list[int], dict]" _parse_runtime_memory_log(str filepath)
Parse Runtime_Memory.log into latest rows by step and final status.
discover_local_project_root(*extra_anchors)
Best-effort source repo discovery from runtime anchors.
_render_run_overview_text(dict summary)
Render run metadata as a compact dashboard.
_diagnostic_resolve_path_or_default(value, str run_dir, str default_filename)
Resolve true/string diagnostics values to a concrete file path.
list check_physical_containment(str run_dir, dict values)
Human-readable physical containment violations.
None add_planned_profile_artifacts(dict plan, dict case_cfg, str run_dir)
Add generated prescribed-flow profile artifacts to a dry-run plan.
"int | None" _find_previous_snapshot_step("list[int]" snapshot_steps, int step)
Return the nearest earlier snapshot step when available.
str _schema_key_hint(dict schema, tuple path, str key, set allowed)
Build a concise typo or hierarchy hint for an unsupported YAML key.
find_workspace_root(*anchors)
Locate the nearest initialized PICurv workspace for supplied anchors.
None validate_programmatic_generated_ic_grid_settings(dict raw_settings)
Validate scalar programmatic grid settings needed by file-generating IC providers.
list expand_study_parameter_combinations(dict study_cfg)
Expand either cartesian-study parameters or explicit parameter sets.
None warn_on_grid_generator_hyphen_keys(dict generator, str case_path, list warnings)
Warn when grid.generator uses unsupported hyphenated wrapper keys.
dict submit_sbatch(str script_path, str dependency=None, str dependency_type="afterok")
Submit sbatch script and return submission metadata.
str directory_value_charset_problem(str value)
Describe why a directory value cannot be written to a PETSc options line.
"tuple[dict, dict, list[int]]" _parse_momentum_convergence_logs(str log_dir)
Parse per-block momentum convergence logs.
"int | None" resolve_particle_console_output_frequency(dict io_cfg)
Return the effective particle-console snapshot cadence from monitor.yml.
_extract_numeric_tuple(str text)
Extract a numeric tuple from a string like '(1, 2, 3)'.
bool discard_unused_run_directory(str run_dir, *bool created)
Remove a generated run directory that never received any content.
validate_and_prepare_boundary_conditions(dict case_cfg)
Validate BC entries against currently supported C-side handlers/types and.
dict _build_selected_asset_payloads(str build_root, dict case_cfg, str case_path, list selected)
Execute existing generators into one isolated run-like build tree.
"dict | None" _infer_study_plot_axis(dict study_cfg, list rows)
Infer the scientifically meaningful independent variable of a study.
submit_staged_local_run(args, dict target_context, list selected_stages)
Execute previously staged local run commands from scheduler/submission.json.
str _format_stage_list(list stage_names)
Format a human-readable stage list for submit diagnostics.
str run_initial_condition_generator(str case_path, str run_dir, dict resolved_ic)
Run the repository IC generator.
init_case(args)
Implements the 'init' command.
str generate_simple_list_file(str run_dir, str run_id, dict cfg, str section, str key, str filename, dict header_sources, str config_dir=None)
Generic function to create a file containing a simple list of strings.
_case_reynolds_number(dict case_cfg)
Reynolds number implied by a case's scaling and fluid properties.
int normalize_les_averaging_mode(value)
Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
persist_post_resume_state(str run_dir, dict plan, last_successful_requested_end_step=None)
Persist post resume lineage metadata for future –continue runs.
render_metrics_aggregate_script(str script_path, str job_name, dict cluster_cfg, str study_dir, str picurv_path)
Generate a single-node sbatch script that runs metrics aggregation.
append_les_parameter_flags(dict les_cfg, list control_lines)
Appends the LES closure parameter flags from a structured les block.
dict resolve_ic_cli_params(dict ic, int finit_code, prepared_blocks, float U_ref)
Resolve all IC parameters and return a dict of PETSc option values.
str _format_optional_step(step)
Format an optional step number for user-facing diagnostics.
"tuple[list, list]" check_post_checkpoint_cadence_alignment(dict post_cfg, dict monitor_cfg, str post_path, str monitor_path="monitor.yml")
Report post step selections that cannot land on a committed checkpoint.
str resolve_run_output_dir(str run_dir, dict monitor_cfg)
Resolve the output data directory within a run directory.
dict merge_execution_overrides("dict | None" base, "dict | None" override)
Merge execution overrides, letting explicit override values win key-by-key.
dict import_workspace_input(str workspace_root, str kind, str source, str name=None, str mode="copy")
Explicitly import or register one workspace input.
optional_matplotlib_pyplot()
Import matplotlib.pyplot lazily for study plot generation.
get_post_input_extensions(dict post_cfg)
Return post input_extensions, preferring io.
dict build_case_asset_graph(dict case_cfg, str case_path)
Classify case inputs into precomputable or simulator-runtime providers.
list _asset_selection(dict graph, requested=None, *bool precomputable_only=False)
Resolve requested asset kinds plus dependency closure.
dict enforce_reproducibility_policy(str workspace_root)
Enforce an optional workspace policy demanding a clean, released build.
_split_error_file_and_message(str raw_error)
Separate a validation error into its source-file and message fields when possible.
str _schema_path_text(tuple path)
Render an internal schema path tuple as a user-facing YAML path.
list _get_recorded_submission_stages(dict target_context)
Return stage names explicitly recorded in scheduler submission metadata.
"tuple[dict, dict, list[int]]" _parse_poisson_convergence_logs(str log_dir)
Parse per-block Poisson convergence logs.
sweep_workflow(args)
Study/sweep orchestration using Slurm job arrays.
str _summary_display_value(value)
Format one configuration-summary value for compact text output.
"tuple[str | None, list[str]]" split_launcher_tokens("str | None" launcher, "list | None" launcher_args=None, str label="launcher")
Canonicalize launcher config into executable token plus argv-style flags.
dict prepare_effective_post_config(dict post_cfg, str resolved_source_dir, int start_step=None, int end_step=None)
Return a copy of post_cfg with resolved source dir and optional effective bounds.
str get_git_commit(str repo_root=None)
Best-effort git commit lookup for run/study manifests and case metadata.
_render_summary_plot_catalog(list catalog, str output_format)
Render available summarize plot-series metadata.
str generate_post_recipe_file(str run_dir, str run_id, dict post_cfg, dict source_files, monitor_cfg=None)
Generates a key=value config file (post.run) for the C post-processor.
dict _read_checkpoint_options(str metadata_path)
Parse the deliberately small PETSc-options checkpoint manifest.
inputs_workflow(args)
Handle explicit workspace input management.
render_slurm_array_stage_script(str script_path, str job_name, dict cluster_cfg, str array_spec, str case_index_tsv, str stage, str solver_exe, str post_exe, str stdout_path, str stderr_path)
Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
_mapping_value_with_aliases(dict mapping, *keys, default=None)
Return the first defined value from a mapping across alias keys.
append_turbulence_flags(dict models, list control_lines)
Appends turbulence model flags from legacy or structured case.yml blocks.
"tuple[str, int]" normalize_initial_condition_field(str value)
Normalize a file IC field selector to its staged basename and C enum value.
format_flag_value(value)
Converts Python types to C-style command-line flag values.
"set[int]" _scan_post_vtk_steps(str prefix_path, str extension)
Collect step numbers from VTK files named with a prefix, step suffix, and extension.
require_existing_case_dir(str case_dir, str purpose, str source_project_root=None)
Validate that a target case directory exists and is not the source repo root.
dict _build_case_overview(dict context)
Build a curated case.yml summary with useful derived quantities.
bool _to_bool(value, str field_name)
Convert a YAML scalar/string to bool with a clear error message.
submit_staged_jobs(args)
Submit previously staged Slurm artifacts from an existing run/study directory.
int normalize_les_filter_width(value)
Maps LES grid-filter-width model names to the C -les_filter_width flag.
tuple resolve_unsafe_paths_override(dict dirs, str monitor_path)
Resolve the unsafe-paths override, requiring a real YAML boolean.
str aggregate_study_metrics(dict study_cfg, list cases, str results_dir)
Collect metric values from generated case directories into one CSV.
dict normalize_post_spectra_config(dict post_cfg)
Validate and canonicalize the spectra block of post.yml.
"tuple[dict, list[int]]" _parse_solution_convergence_log(str filepath)
Parse solution_convergence.log into latest rows by step plus observed order.
dict detect_post_source_frontier(str source_dir, dict monitor_cfg, dict post_cfg, int start_step, int end_step, int step_interval)
Detect the highest contiguous fully available source step for live post-processing.
str resolve_post_source_directory(str run_dir, dict monitor_cfg, dict post_cfg, bool strict=True)
Resolve post source directory token and optionally enforce existence.
bool _post_requests_field_statistics(dict post_cfg)
Return whether the current post recipe derives accumulated field statistics.
dict _build_solver_overview(dict context)
Build a curated solver.yml summary with normalized selections.
sync_case_template_files(str case_dir, str template_dir, bool overwrite=False, bool prune=False, managed_rel_paths=None)
Sync template files into a case directory, preserving modified files unless overwrite is requested.
dict detect_post_completed_frontier(str run_dir, dict post_cfg, monitor_cfg, int start_step, int end_step, int step_interval)
Detect the highest contiguous fully completed post step for the current recipe.
write_yaml_file(str filepath, dict data)
Write YAML with stable ordering for generated study artifacts.
list _spectra_mean_arguments(dict task_cfg, dict bundle, dict mean_bundle=None)
Build the generator arguments implementing a task's fluctuation choice.
dict _parse_particle_snapshot_file(str filepath)
Parse sampled particle snapshots from a solver stream log.
source_workflow(args)
Fetch source history without silently changing the active code.
_read_json_if_exists(str filepath)
Read JSON when present, otherwise return None.
str _resolve_generator_script(str configured_script, str case_path, str default_name)
Resolve an optional generator script override or repository default.
str _command_to_string(list command_tokens)
Render a command list as a shell-safe display string.
float _to_float(value, str field_name)
Convert a YAML scalar to float with a clear error message.
str _stable_mapping_sha256(payload)
Hash a JSON-compatible value with deterministic serialization.
dict _require_summary_config(dict context, str name)
Return one explicitly requested copied config or fail with a structured error.
str parse_slurm_job_id(str sbatch_output)
Extract numeric job id from standard sbatch output.
str resolve_command_log_path(str run_dir, str log_filename)
Resolve a command log filename relative to the run directory.
"float | None" _summary_physical_time(dict context, dict record)
Resolve a record's physical time from its artifact or copied case configuration.
"tuple[list, dict]" build_post_locked_command(str run_dir, str recipe_fingerprint, list wrapped_command, bool create_wrapper=True)
Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
str _format_study_group_value(value)
Format a secondary study parameter compactly for a legend.
int normalize_flow_direction_token(str value)
Maps a face-token flow direction string to the C FlowDirection enum integer.
bool _study_use_log_scale(list values, bool semantic_hint=False)
Use log scaling only for positive data spanning a meaningful range.
int normalize_rans_model(value)
Maps RANS model selectors to the current C -rans switch.
str resolve_runtime_executable(str executable_name)
Resolve solver/post executable path, preferring local sibling binaries.
list _asset_payload_files(str build_root, str kind)
Enumerate canonical files belonging to one asset kind in a build tree.
list _collect_summary_plot_records(dict context)
Collect append-ordered numeric records from summarize-supported scalar logs.
"list[set[int]]" collect_post_completion_families(str run_dir, dict post_cfg, monitor_cfg=None)
Collect per-family completed-step sets for the current post recipe.
"tuple[dict, list[int]]" _parse_continuity_metrics_log(str filepath)
Parse Continuity_Metrics.log into latest rows by step plus observed order.
sync_case_config_command(args)
Refresh template-managed config/docs files in a case directory.
dict enforce_workspace_version(str workspace_root)
Enforce an optional workspace PICurv version requirement.
str resolve_recipe_spectra_output_dir(dict post_cfg, monitor_cfg=None)
Resolve the canonical spectra directory for one versioned recipe.
list build_identity_problems(dict identities, workspace_requirement=None)
Report every reason the active build identity is not internally coherent.
build_project(args)
Implements the 'build' command.
validate_eulerian_checkpoint(str source_dir, int step, dict monitor_cfg)
Validate the mandatory Eulerian field set required by ReadSimulationFields().
int normalize_field_init_mode(str value)
Maps canonical field init mode names to C enum/int codes (-finit).
require_project_root(str candidate, str purpose)
Validate that a source repo root was resolved and is structurally valid.
"list[str]" strip_launcher_size_flags(str launcher_name, "list[str]" launcher_args)
Remove explicit MPI task-count flags from known launchers.
str resolve_latest_restart_run(dict case_cfg, str case_path, int start_step)
Select the newest local workspace run compatible with a requested restart.
str allocate_generated_run_id(str runs_root, dict case_cfg, str case_path)
Build the generated run identity, disambiguating a same-second collision.
fail_cli_usage(str message, str hint=None)
Emit a structured CLI usage error and exit with code 2.
dict precompute_case_assets(str workspace_root, dict case_cfg, str case_path, requested=None, bool precomputable_only=False)
Build and publish a selected deterministic asset dependency closure.
dict _diagnostic_info(value)
Validate PETSc info logging configuration.
dict validate_and_nondimensionalize_picgrid(str source_grid, str dest_grid, float L_ref, int expected_nblk=None)
Validates PICGRID payload and writes a non-dimensionalized copy.
str _write_workspace_asset_set(str workspace_root, str case_path, dict graph, dict references)
Atomically update the named asset set and workspace asset catalog.
_drop_imported_package(str package_name)
Remove a failed/partial import package tree from sys.modules.
_nearest_step("set[int]" steps, int target)
Return the complete source step nearest to a target step.
_print_config_header(str title, "str | None" subtitle=None)
Print a strong dashboard-style configuration summary header.
str _asset_file_sha256(str path)
Hash an asset source or payload without loading it into memory.
dict stage_initial_condition_file(str run_dir, str case_path, dict resolved_ic)
Materialize and stage one file-backed IC in ReadFieldData's expected layout.
dict resolve_fluid_scaling(dict case_cfg)
Resolve the shared physical and nondimensional fluid scaling contract.
dict validate_and_nondimensionalize_picslice(str source_slice, str dest_slice, float U_ref, tuple expected_dims=None)
Validate a canonical PICSLICE payload and write a solver-scale copy.
str _sanitize_error_field(value)
Normalize error fields into a single-line string.
dict _get_submission_stage_metadata(dict target_context, str stage_name)
Return stored metadata for one staged submission target.
validate_post_config(dict post_cfg, str post_path, dict monitor_cfg=None, dict case_cfg=None)
Validates the post-processing config before running the post-processor.
dict validate_newton_krylov_config(dict cfg)
Validate and normalize the structured Newton–Krylov solver block.
bool has_explicit_monitor_whitelist(dict monitor_cfg)
Return True when logging.enabled_functions contains at least one entry.
dict read_binary_build_identity(str executable_path)
Read the build identity a native executable was compiled with.
tuple staged_control_directories(str control_path)
Read run-owned directory values from a staged control file.
set _les_periodic_axes(dict case_cfg)
Reports which logical axes a case declares periodic on both faces.
bool is_project_root(str candidate)
Return True when a directory looks like the PICurv source repository root.
list build_local_launch_command(str executable, list executable_args, int num_procs, str config_search_anchor=None, bool allow_single_rank_launcher_override=False, "int | None" force_num_procs=None)
Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
int normalize_les_test_filter(value)
Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
str normalize_les_averaging_directions(value)
Maps a list of homogeneous logical directions to the C flag's string form.
validate_cluster_config(dict cluster_cfg, str cluster_path)
Validate Slurm scheduler configuration from cluster.yml.
subprocess.CompletedProcess _run_captured_command(list command, str run_dir)
Run a command and capture combined stdout/stderr details for later inspection.
write_case_origin_metadata(str case_dir, str source_project_root, str template_name=None, dict existing=None, template_managed_files=None)
Create or refresh case-origin metadata for repo-aware case maintenance commands.
dict resolve_diagnostics_config(dict monitor_cfg, "str | None" run_dir=None, str stage_label="Solver")
Resolve monitor diagnostics config and default run-local log paths.
list_source_binaries(str source_project_root)
List binary artifacts currently available in the source repo bin directory.
bool _post_needs_particle_source(dict post_cfg)
Return whether the current post recipe requires particle source files to be present.
_print_config_group(str title, list rows)
Print an aligned configuration-summary field group.
resolve_case_origin_context(str case_dir_hint=None, str source_root_override=None, str template_name_override=None)
Resolve case directory, source repo root, and optional template metadata.
_iter_nonempty_noncomment_lines(file_obj)
Yield (lineno, stripped_line) for non-empty, non-comment lines.
str normalize_eulerian_field_source(str value)
Normalizes the Eulerian field source selector to the C-side canonical string.
dict build_software_lock()
Capture the exact software identity a run is about to execute with.
dict load_active_run_configuration(str run_dir)
Load the active immutable configuration revision for a run.
dict organize_initialized_workspace(str workspace_root, str template_name, str source_template_root=None)
Convert a copied example into the canonical editable workspace layout.
str case_run_label(dict case_cfg, str case_path)
Resolve the stable human-facing portion of a generated run identifier.
dict build_run_lineage(str parent_run_dir, int checkpoint_step, *workspace_root=None, statistics_state=None, requested_source=None)
Record which run and which checkpoint a branched run was started from.
_diagnostic_bool_or_path(value, str key)
Validate a diagnostics value that can be false, true, or a path/viewer string.
dict resolve_profiling_config(dict monitor_cfg)
Resolve profiling reporting config from monitor.yml.
None _validate_yaml_schema_keys(cfg, dict schema, str file_path, list errors, tuple path=())
Reject unsupported YAML keys before they can be silently ignored by staging.
bool _write_structured_grid_preview(str picgrid_path, str destination, dims)
Write a single-block ASCII VTS preview of a staged PICGRID.
dict _build_asset_inspection(str kind, str build_root, dict provider, list payload_files)
Produce the inspection material published beside an asset's payload.
dict get_post_lock_paths(str run_dir, str recipe_id=None)
Return lock-wrapper related paths for a run directory.
"tuple[int, int, int]" resolve_post_requested_window(dict post_cfg, dict case_cfg=None)
Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
str _classify_error_code(str message)
Map existing validation/error messages to the standardized code set.
subprocess.CompletedProcess _git_source_command(list arguments)
Run a checked Git command against the active PICurv source checkout.
"tuple[str | None, list[str]]" normalize_cluster_launcher(dict execution)
Canonicalize cluster launcher config into executable token plus argv-style flags.
dict _compute_particle_snapshot_delta("list[dict]" current_rows, "list[dict]" previous_rows)
Compute sampled deltas between two particle snapshot samples.
"str | None" resolve_runtime_execution_seed_source(str source_project_root)
Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
"list[str]" _find_solver_stream_log_candidates(str run_dir, str log_dir)
Return plausible solver stream logs for local and Slurm runs.
str _summary_field_label(str field)
Return the report-facing label for one logged scalar field.
_lookup_allowed_schema_keys(dict schema, tuple path)
Return allowed keys for a path, honoring '*' dynamic mapping entries.
dict _build_particle_snapshot_summary(str source, int step, "list[dict]" rows, int preview_rows, particle_console_output_freq, particle_log_interval, "int | None" previous_step=None, "list[dict] | None" previous_rows=None)
Build sampled diagnostics for one particle console snapshot.
int normalize_wall_function_model(value)
Maps wall-function model selectors to the C -wallfunction flag.
dict _build_monitor_overview(dict context)
Build a curated monitor.yml summary with resolved defaults.
dict _provider_source_fingerprints(value, str case_path, str key="")
Hash every existing file explicitly referenced by an asset provider.
bool _ic_has_inlet(prepared_blocks)
Return True if any prepared BC block contains an INLET face.
dict generate_field_slice_picslice(str output_path, tuple expected_dims, dict source, str target_grid, str target_face, int target_block, str case_path)
Invoke profile.gen to extract a field_slice PICSLICE artifact.
str write_runtime_execution_file(str filepath, str template_source_path=None)
Write a default runtime execution config, copying a source template when available.
str compute_physical_case_identity(dict case_cfg)
Compute the hidden identity used to guard in-place continuation.
None add_planned_initial_condition_artifacts(dict plan, dict case_cfg, dict solver_cfg, str run_dir)
Add authoritative file-backed initial-condition artifacts to a dry-run plan.
dict validate_petsc_vec_binary(str path)
Validate the basic PETSc binary VecView envelope used by ReadFieldData.
"tuple[float, float] | None" _study_linear_y_limits(list values)
Build padded linear limits that include zero for non-negative metrics.
str get_post_statistics_output_prefix(dict post_cfg, str default="Stats")
Resolve the statistics CSV prefix, preserving legacy top-level override support.
_print_validation_errors(list errors)
Prints validation errors and exits.
detect_last_checkpoint_step(str output_dir)
Scan output directory for the highest step number available.
dict build_post_execution_plan(str run_dir, str run_id, dict case_cfg, dict monitor_cfg, dict post_cfg, bool continue_requested=False, bool allow_source_frontier_scan=True)
Resolve post resume/source-availability behavior into one execution plan.
_invoke_plot_gen(dict request)
Invoke standalone plot.gen with one normalized request over stdin.
list get_study_parameter_keys(dict study_cfg)
Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter s...
"tuple[set[int], dict]" _scan_complete_source_steps(str source_dir, dict monitor_cfg, dict post_cfg)
Scan source artifacts and return steps with every file required by the recipe.
dict resolve_grid_da_processor_layout(dict grid_cfg)
Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
dict flatten_study_parameters(dict parameters)
Flatten grouped study overrides into scalar dotted-path columns.
str format_command_for_display(list command)
Render a shell-safe command string for console and log output.
_diagnostic_bool_or_all(value, str key)
Validate a diagnostics value that can be false, true, or "all".
"dict | None" resolve_walltime_guard_policy("dict | None" cluster_cfg)
Resolve the effective Slurm walltime-guard policy for generated solver jobs.
dict resolve_solution_monitoring_flags(dict monitor_cfg)
Translate solution-monitoring YAML into the existing C convergence flags.
auto_identify_run_inputs(str config_dir)
Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
dict _resolve_submission_target(str run_dir=None, str study_dir=None)
Resolve a run/study submission target from explicit directory flags.
tuple validate_run_directory_structure(str run_dir)
Refuse a run whose root has grown a directory the layout does not define.
bool _post_requests_statistics(dict post_cfg)
Return whether the current post recipe expects statistics CSV artifacts.
str _humanize_plot_identifier(str value)
Convert one machine-oriented identifier into a readable plot label.
sweep_continue_workflow(args)
Continue a partially-completed Slurm parameter sweep study.
bool _is_summary_plot_continuation_marker(str line)
Return whether a log line starts a new continuation segment.
get_post_run_control_value(dict post_cfg, str canonical_key, default=None)
Resolve post run_control values with backwards-compatible legacy aliases.
str generate_header(str run_id, dict source_files)
Creates a standard header block for all generated files.
str run_grid_generator(str case_path, str run_dir, dict grid_cfg, dict case_cfg=None)
Runs generators/grid.gen to produce a PICGRID file for this run.
str _study_metric_label(dict study_cfg, str metric)
Resolve an optional configured metric label or humanize its name.
list reject_generator_destination_keys(generator, str case_path, str label)
Reject generator settings that try to choose their own output destination.
str _resolve_spectra_payload(dict bundle, str kind, str field, int block)
Locate one checkpoint payload by its inventory entry rather than by path shape.
dict resolve_initial_condition_config(dict ic, prepared_blocks, float U_ref, provider_context=None)
Resolve legacy and structured initial-condition YAML into one launcher contract.
str normalize_analytical_type(str value)
Normalizes the analytical solution selector to the C-side canonical string.
str _resolve_post_source_directory_preview(str run_dir, dict monitor_cfg, dict post_cfg)
Resolve post source directory without side effects or stdout/stderr output.
render_slurm_script(str script_path, str job_name, dict cluster_cfg, list command, str workdir, str stdout_path, str stderr_path=None, dict env_vars=None, dict shell_env_vars=None, str array_spec=None, list follow_commands=None)
Render a Slurm batch script for a single command.
bool _statistics_subsystem_available(dict case_cfg, requirement)
Report whether the subsystem a statistics field depends on is active.
dict _build_spectrum_plot_request(dict context, str task, bool reference, bool linear_y, "str | None" output_path)
Build a plot.gen request drawing representative measured spectra.
str format_picgrid_coordinate(float value)
Format a coordinate with round-trip-safe binary64 precision.
str _resolve_run_artifact_path(str run_dir, str configured_path, str default_path, bool default_to_config_dir=False)
Resolve a run artifact path with run-dir-relative defaults.
dict normalize_post_recipe_signature(dict recipe_cfg)
Normalize post recipe settings into a stable signature mapping.
list build_spectra_follow_command(str run_dir, str post_path, dict post_cfg)
Build the batch-script step that measures spectra after the field stage.
"tuple[dict, list[int]]" _parse_particle_metrics_log(str filepath)
Parse Particle_Metrics.log into latest rows by step plus observed order.
"set[int]" _scan_post_statistics_csv_steps(str csv_path)
Scan step ids from the first CSV column of a statistics artifact.
normalize_metric_spec(metric)
Normalize study metric definitions to a common dictionary form.
dict _normalize_prescribed_flow_source(source, str field_name)
Validate the structured source block for prescribed_flow BCs.
get_post_statistics_task_tokens(dict post_cfg)
Return normalized statistics pipeline tokens that will be written into post.run.
None validate_continue_case_identity(str run_dir, dict case_cfg)
Reject in-place continuation when the physical case has changed.
dict _publish_asset_object(str workspace_root, str build_root, dict provider, list payload_files)
Publish one immutable content-addressed asset object atomically.
float _to_finite_float(value, str field_name)
Convert a non-boolean YAML scalar to a finite float.
infer_plot_x_axis(dict study_cfg, list rows)
Infer x-axis key/values for study plots.
str _read_release_version()
Read the single release version shared by every PICurv executable.
parse_and_add_model_flags(dict case_cfg, list control_lines)
Parses the 'models' section of case.yml and adds corresponding C-solver flags.
"list[str]" _expected_source_paths_for_step(int step, dict source_scan, dict post_cfg)
Build required source file paths for a single post-processing step.
_relative_to_workspace(str path, str workspace_root)
Return a portable workspace-relative path when possible.
str _format_summary_float(value, str spec=".6e", str missing="n/a")
Format optional numeric values for summary text output.
str _workspace_asset_set_name(str workspace_root, str case_path)
Return a collision-free, readable mutable asset-set name.
find_project_root_upwards(str start_path)
Search upward from an anchor and return the first matching project root.
dict normalize_solution_monitoring_config(dict monitor_cfg)
Validate and canonicalize physical-solution convergence monitoring.
dict _normalize_field_slice_source(source, str field_name)
Validate a prescribed_flow field_slice source block.
validate_les_configuration(dict case_cfg, dict les_cfg, str case_path, list errors, list warnings)
Checks the structured LES block for values the closure cannot honour.
dict resolve_cluster_execution(dict cluster_cfg, str config_search_anchor=None, extra_search_anchors=None)
Resolve cluster execution launcher settings from shared runtime config plus cluster....
generate_study_plots(dict study_cfg, str metrics_csv, str plots_dir)
Generate metric-vs-parameter plots for completed studies.
dict _run_component_states(str run_dir, dict stages_requested)
Report each run component's home, retention class, and lifecycle state.
str get_post_source_directory_template(dict post_cfg, str default="<solver_output_dir>")
Resolve the source directory template from source_data with a safe default.
dict _get_git_head_state(str run_dir)
Capture the current git HEAD branch name and commit hash.
render_run_dry_plan(dict plan, str output_format="text")
Render dry-run plan in human or JSON format.
list resolve_field_statistics_flags(dict monitor_cfg, dict case_cfg=None)
Serialize field-statistics configuration into control-file option lines.
list resolve_conductor_entry_point()
Resolve how to invoke this conductor again from a batch script.
dict build_run_dry_plan(args)
Build a no-write execution plan for run --dry-run.
_file_sha256(str path)
Content digest of one file, or None when it cannot be read.
load_runtime_execution_config(str config_search_anchor=None, extra_search_anchors=None)
Load optional shared execution launcher config from the nearest runtime config file.
list _study_plot_groups(dict study_cfg, list rows, dict axis, str metric)
Group metric points by any secondary varied study parameters.
dict _normalize_field_slice_selector(slice_cfg, str field_name)
Validate the field_slice slice selector.
_render_solver_summary_text(dict summary)
Render the solver summary as a glanceable numerical-method dashboard.
append_passthrough_flags(list control_lines, dict options)
Appends raw CLI flags to the control list from a {flag: value} dict.
float _summary_source_mtime(paths)
Return the newest modification time among one or more summary sources.
str initialize_workspace_root(str workspace_root, str template_name)
Create the workspace skeleton and its identity file at one root.
tuple evaluate_run_directories(dict values, bool override, set explicit=None)
Apply every run-directory safety rule to a set of effective directory values.
dict build_post_recipe_config(dict post_cfg, monitor_cfg=None)
Build the flat key=value mapping consumed by the C post-processor.
find_case_origin_metadata_file(str case_dir_hint=None)
Find the nearest case-origin metadata file from known runtime anchors.
str resolve_target_grid_for_field_slice(dict case_cfg, str case_path, str run_dir)
Resolve the target canonical PICGRID path needed for field_slice normals.
_prune_incompatible_python_site_paths(paths)
Remove site-package paths for a different Python major/minor version.
_choose_primary_workspace_role(list candidates, str role, str template_name)
Select the canonical role file from a template that may carry variants.
tuple preflight_staged_run_directories(str root_dir)
Re-check run-directory safety against an already-staged run or study.
_deep_set(dict container, str dotted_path, value)
Set nested dictionary value, creating intermediate maps when needed.
cancel_run_jobs(args)
Cancel Slurm-submitted jobs for an existing run directory.
str normalize_extension(str ext)
Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
dict validate_committed_checkpoint(str source_dir, int step, bool require_particles=False)
Validate one committed bundle using the same manifest contract as C.
int normalize_interpolation_method(str value)
Maps interpolation method names to C enum/int codes (-interpolation_method).
None add_planned_grid_artifacts(dict plan, dict case_cfg, str run_dir)
Add grid-mode-specific staged artifacts to a dry-run plan.
bool resolve_enabled_flag(dict cfg, str path, bool default=True)
Resolves a structured enabled flag and rejects non-boolean values.
generate_solver_control_file(run_dir, run_id, configs, num_procs, monitor_files, restart_source_dir=None, continue_mode=False, str config_dir=None)
Generates the main .control file for the C-solver.
dict _toolchain_identity()
Best-effort record of the PETSc, MPI, and compiler the binaries were built on.
dict _find_particle_snapshot_for_step(str run_dir, str log_dir, int step, int preview_rows, particle_console_output_freq, particle_log_interval)
Locate and summarize a particle console snapshot for one step.
dict _build_summary_context(str run_dir)
Resolve run-local config and artifact paths for summarize.
dict effective_run_directories(dict configured)
Fill in defaults for run-owned directories that were not configured.
extract_metric_from_log(str case_dir, dict spec)
Extract a scalar metric from a log file using regex.
load_case_origin_metadata(str case_dir_hint=None)
Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
str normalized_run_directory(str value)
Normalized, comparable form of a contained run directory value.
str _extract_key_path(str message)
Best-effort key-path extraction from free-form validation messages.
_iter_post_steps(int start_step, int end_step, int step_interval)
Yield configured post-processing steps inclusively.
dict resolve_solver_monitoring_flags(dict monitor_cfg)
Resolve human-readable solver monitoring YAML to raw control flags.
dict plan_run_assets(dict case_cfg, str case_path)
Plan reuse/build/runtime actions for all configured run providers.
list get_post_spectra_output_artifacts(dict post_cfg, str run_dir, monitor_cfg=None)
Predict the spectra CSV paths a recipe will write.
str post_spectra_task_basename(dict task_cfg, str output_prefix)
Build the file basename one normalized spectra task writes.
list resolve_grid_block_dimensions_for_profiles(dict case_cfg, str case_path, str run_dir=None)
Resolve per-block node dimensions for prescribed inlet profile validation.
_workspace_requested_version(str workspace_root)
Resolve an exact version requested by an initialized workspace.
str _diagnostic_default_file(str run_dir, str filename)
Return an absolute run-local diagnostics file path.
"set[int]" _scan_committed_checkpoint_steps(str source_dir, bool require_particles=False)
Return only fully validated, committed checkpoint steps.
_rewrite_workspace_path_values(value, dict replacements)
Rewrite copied template path scalars to workspace-root-relative homes.
require_numpy()
Import NumPy only for commands that need numeric reductions.
dict prepare_monitor_files(str run_dir, str run_id, dict monitor_cfg, dict source_files, str config_dir=None)
Generate monitor sidecar files and resolve profiling reporting behavior.
list _flatten_summary_mapping(dict mapping, str prefix="")
Flatten nested summary mappings into readable dotted field rows.
None enforce_run_directory_structure(str run_dir)
Apply validate_run_directory_structure() as a refusal at run time.
dict run_post_spectra_stage(str run_dir, dict post_cfg, dict monitor_cfg, str source_dir, steps, bool quiet=False)
Measure spectra for every requested task across a window of committed steps.
versions_workflow(args)
List or activate a release using the existing source/build owners.
list build_cluster_launch_command(dict cluster_cfg, str executable, list executable_args, str config_search_anchor=None, extra_search_anchors=None, "int | None" force_num_procs=None)
Build scheduler launcher command from cluster config plus optional shared execution defaults.
bool is_generated_ic_provider(dict resolved_ic)
Return whether a resolved IC is backed by a registered file generator.
validate_particle_checkpoint(str source_dir, int start_step, dict monitor_cfg)
Validate that particle checkpoint files exist for the given step.
str _study_parameter_label(str key)
Return a concise report label for a study parameter path.
parse_post_recipe_file(str post_recipe_path)
Parse an existing generated post.run file into a key/value mapping.
"str | None" infer_unique_inlet_axis_from_prepared_bcs(list prepared_blocks)
Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
int parse_slurm_time_limit_to_seconds(str time_text)
Parse a Slurm time-limit string into total seconds.
prepare_case_for_continuation(str run_dir, str case_id, int last_step, int target_final_step, dict cluster_cfg)
Set up a partially-completed study case for continuation in-place.
None ensure_run_layout(str run_dir)
Materialize the uniform, cheap directory skeleton for one run.
int get_cluster_total_tasks(dict cluster_cfg)
Return cluster total tasks.
dict read_monitor_from_run(str run_dir)
Read the monitor.yml from a run directory's config/ subdirectory.
dict normalize_field_statistics_config(dict monitor_cfg, dict case_cfg=None)
Validate and canonicalize the field-statistics block of monitor.yml.
"list[list[int]]" _order_summary_step_orders("list[tuple[list[int], object]]" sources)
Order observed step sequences by the recency of their source files.
bool _diagnostic_bool(value, str key)
Validate a diagnostics boolean value.
"tuple[float, float, float]" parse_initial_velocity_components(dict initial_conditions, int finit_code, *bool require_explicit)
Parse initial-condition velocity components with mode-aware defaults.
"list | None" _numeric_study_column(list rows, str key)
Parse one complete, finite numeric study-table column.
print_case_source_status(dict status)
Render human-readable source/case drift details.
dict read_yaml_file(str filepath)
Safely reads a YAML file and returns its content.
precompute_workflow(args)
Resolve, preflight, and atomically publish reusable workspace assets.
dict parse_solver_config(dict solver_cfg)
Parses the structured solver.yml into a flat dictionary of {flag: value}.
run_workflow(args)
Main orchestrator for the 'run' command (local and Slurm modes).
dict resolve_runtime_execution_context(dict runtime_execution_cfg, str context)
Resolve default plus context-specific execution overrides.
pull_source_repo(args)
Refresh source branches in the repository resolved from a case directory.
"tuple[dict, str]" apply_canonical_post_paths(dict post_cfg, str run_dir)
Route every post artifact into its fixed analysis or visualization home.
list _collect_spectra_plot_records(dict context)
Collect the per-step scalar histories written by the spectra post stage.
list validate_post_spectra_preconditions(dict spectra_cfg, dict case_cfg, str post_path)
Check spectra tasks against what the case can actually support.
str compute_post_recipe_id(dict post_cfg)
Compute a stable human-readable identity for one post recipe.
resolve_template_directory(str source_project_root, str template_name)
Resolve an example template directory inside the source repository.
Head of a generic C-style linked list.