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.
36from datetime
import datetime
37from io
import StringIO
43from pathlib
import Path
44from xml.etree
import ElementTree
47 from .storage
import (
50 restore_cold_study_members,
52 read_artifact_identity,
53 STORAGE_LOCK_FILENAME,
54 STORAGE_STATE_FILENAME,
55 require_storage_payload_local,
57 storage_state_summary,
58 _rebase_restored_text_paths,
64 _package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
65 if _package_parent
not in sys.path:
66 sys.path.insert(0, _package_parent)
67 _storage_module = importlib.import_module(
"picurv_cli.storage")
68 StorageError = _storage_module.StorageError
69 cold_study_members = _storage_module.cold_study_members
70 restore_cold_study_members = _storage_module.restore_cold_study_members
71 is_artifact_cold = _storage_module.is_artifact_cold
72 read_artifact_identity = _storage_module.read_artifact_identity
73 STORAGE_LOCK_FILENAME = _storage_module.STORAGE_LOCK_FILENAME
74 STORAGE_STATE_FILENAME = _storage_module.STORAGE_STATE_FILENAME
75 require_storage_payload_local = _storage_module.require_storage_payload_local
76 runtime_stage_lock = _storage_module.runtime_stage_lock
77 storage_state_summary = _storage_module.storage_state_summary
78 _rebase_restored_text_paths = _storage_module._rebase_restored_text_paths
81_MATPLOTLIB_PYPLOT =
None
86 @brief Module-like proxy that preserves `picurv.np` without eager import.
91 @brief Resolve a NumPy attribute on first use.
92 @param[in] name NumPy attribute name.
93 @return Requested NumPy attribute.
103 @brief Remove site-package paths for a different Python major/minor version.
104 @param[in] paths Candidate sys.path entries.
105 @return Filtered path list.
107 current = (sys.version_info[0], sys.version_info[1])
108 pattern = re.compile(
r"python(?:-)?(\d+)\.(\d+)", re.IGNORECASE)
112 match = pattern.search(text)
114 path_version = (int(match.group(1)), int(match.group(2)))
115 if path_version != current
and (
"site-packages" in text
or "dist-packages" in text):
117 filtered.append(path)
123 @brief Remove a failed/partial import package tree from sys.modules.
124 @param[in] package_name Top-level package name.
126 prefix = package_name +
"."
127 for module_name
in list(sys.modules):
128 if module_name == package_name
or module_name.startswith(prefix):
129 sys.modules.pop(module_name,
None)
134 @brief Import NumPy only for commands that need numeric reductions.
135 @return Imported NumPy module.
138 if _NUMPY_MODULE
is not None:
142 except Exception
as exc:
144 original_path =
list(sys.path)
149 except Exception
as retry_exc:
151 "NumPy is required for this operation, but no compatible NumPy "
152 "could be imported for this Python interpreter. PICurv ignored "
153 "site-packages paths for other Python versions and retried. "
154 f
"First error: {first_error}. Retry error: {retry_exc}"
157 sys.path = original_path
158 _NUMPY_MODULE = numpy
164 @brief Import matplotlib.pyplot lazily for study plot generation.
165 @return matplotlib.pyplot when available, otherwise None.
167 global _MATPLOTLIB_PYPLOT
168 if _MATPLOTLIB_PYPLOT
is not None:
169 return _MATPLOTLIB_PYPLOT
170 original_path =
list(sys.path)
172 import matplotlib.pyplot
as pyplot
177 import matplotlib.pyplot
as pyplot
181 sys.path = original_path
182 _MATPLOTLIB_PYPLOT = pyplot
183 return _MATPLOTLIB_PYPLOT
188PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))
189PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)
190INVOKED_SCRIPT_DIR = os.environ.get(
191 "_PICURV_INVOKED_SCRIPT_DIR",
194SCRIPT_PATH = os.environ.get(
195 "_PICURV_SCRIPT_PATH",
198PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)
199GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT,
"generators")
200if os.path.basename(SCRIPT_PATH) ==
"bin":
201 DEFAULT_BIN_DIR = SCRIPT_PATH
203 DEFAULT_BIN_DIR = os.path.join(PROJECT_ROOT,
"bin")
205VERSION_FILE = os.path.join(PACKAGE_PROJECT_ROOT,
"VERSION")
210 @brief Read the single release version shared by every PICurv executable.
211 @return Release version from VERSION, or a safe development fallback.
213 configured = os.environ.get(
"PICURV_RELEASE_VERSION")
215 return configured.strip()
217 with open(VERSION_FILE,
"r", encoding=
"utf-8")
as stream:
218 value = stream.read().strip()
221 return value
or "0.0.0"
226 @brief Run one git command in the source checkout.
227 @param[in] arguments Git arguments.
228 @return Stripped standard output, or None when git is unavailable or the command fails.
231 result = subprocess.run([
"git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
232 text=
True, capture_output=
True, check=
False)
235 return result.stdout.strip()
if result.returncode == 0
else None
240 @brief Resolve reproducible release, commit, and dirty-tree build identity.
241 @details One `git describe --long --dirty` gives the commit, its distance from the
242 release tag, and whether tracked files differ from it (untracked files are
243 ignored, as `git status --untracked-files=no` did). Only when the tag is
244 missing does it fall back to the commit, a dirty check, and the commit count.
245 On a cold network filesystem each git call costs seconds, which is why this is
246 reached lazily through picurv_build() rather than at import.
247 @param[in] release_version Release version read from VERSION.
248 @return Mapping suitable for manifests and user-facing status output.
251 "release_version": release_version,
252 "version": release_version,
254 "git_short_commit":
None,
256 "build_id": release_version,
257 "dev_distance":
None,
260 described =
_git_output(
"describe",
"--tags",
"--long",
"--dirty",
"--abbrev=40",
261 "--match", f
"v{release_version}")
262 match = re.match(
r"^v.+-(\d+)-g([0-9a-f]{40})(-dirty)?$", described
or "")
264 identity[
"dev_distance"] = int(match.group(1))
265 identity[
"git_commit"] = match.group(2)
266 identity[
"dirty"] = bool(match.group(3))
269 identity[
"git_commit"] =
_git_output(
"rev-parse",
"HEAD")
270 status =
_git_output(
"status",
"--porcelain",
"--untracked-files=no")
271 identity[
"dirty"] =
None if status
is None else bool(status)
273 if count
is not None and count.isdigit():
274 identity[
"dev_distance"] = int(count)
275 if identity[
"git_commit"]:
276 identity[
"git_short_commit"] = identity[
"git_commit"][:12]
279 identity[
"released"] = identity[
"dev_distance"] == 0
and not identity[
"dirty"]
280 if identity[
"git_short_commit"]:
281 development =
"" if identity[
"dev_distance"]
in (0,
None)
else f
".dev{identity['dev_distance']}"
282 suffix = f
"{development}+g{identity['git_short_commit']}"
283 if identity[
"dirty"]:
285 identity[
"build_id"] = release_version + suffix
286 identity[
"version"] = identity[
"build_id"]
290@functools.lru_cache(maxsize=None)
293 @brief The conductor's own build identity, resolved on first use and kept for the process.
294 @details Commands that only parse, validate, or scaffold never touch git; manifests,
295 locks, and `picurv version` do, once. Callers that store it copy it.
296 @return The cached identity mapping from _source_build_identity().
302CASE_ORIGIN_METADATA_FILENAME =
".picurv-origin.json"
303WORKSPACE_CONFIG_FILENAME =
".picurv-workspace.yml"
304WORKSPACE_SCHEMA_VERSION = 1
305RUN_MANIFEST_SCHEMA_VERSION = 3
306ASSET_MANIFEST_SCHEMA_VERSION = 1
307ASSET_LOCK_SCHEMA_VERSION = 1
308RUNTIME_EXECUTION_CONFIG_FILENAME =
".picurv-execution.yml"
309LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME =
".picurv-local.yml"
310RUNTIME_EXECUTION_EXAMPLE_FILENAME =
"execution.example.yml"
311RUNTIME_EXECUTION_CONFIG_FILENAMES = (
312 RUNTIME_EXECUTION_CONFIG_FILENAME,
313 LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME,
316DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE =
"""# Optional shared runtime launcher overrides.
317# This file is safe to leave unchanged on ordinary local machines.
318# Edit it only when your site needs custom MPI launcher tokens.
321# - local/login-node runs: local_execution -> default_execution -> built-in mpiexec
322# - generated cluster jobs: cluster.yml.execution -> cluster_execution -> default_execution -> built-in srun
337CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT =
"my_project_account"
338CLUSTER_TEMPLATE_PLACEHOLDER_MAIL =
"user@example.edu"
340DEFAULT_WALLTIME_GUARD_POLICY = {
345 "estimator_alpha": 0.35,
347WALLTIME_GUARD_ENV_JOB_START_EPOCH =
"PICURV_JOB_START_EPOCH"
348WALLTIME_GUARD_ENV_LIMIT_SECONDS =
"PICURV_WALLTIME_LIMIT_SECONDS"
349POST_RESUME_STATE_FILENAME =
"post.resume.json"
350POST_LOCK_FILENAME =
"post.lock"
351POST_LOCK_METADATA_FILENAME =
"post.lock.json"
352POST_LOCK_WRAPPER_FILENAME =
"post_lock_wrapper.py"
353POST_RESUME_SCHEMA_VERSION = 2
356POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {
"startTime",
"endTime",
"timeStep",
"source_directory"}
357CHECKPOINT_FORMAT =
"picurv-checkpoint"
358CHECKPOINT_VERSION = 1
359CHECKPOINT_STEP_WIDTH = 12
360CHECKPOINT_REQUIRED_EULERIAN_FIELDS = {
"Ucat",
"Ucont",
"Ucont_rm1",
"P",
"Nvert"}
362WORKSPACE_DIRECTORY_LAYOUT = (
366 "config/initial_conditions",
367 "config/inlet_profiles",
370 "inputs/initial_conditions",
371 "inputs/inlet_profiles",
372 "inputs/reference_fields",
375 "assets/objects/grids",
376 "assets/objects/initial_conditions",
377 "assets/objects/inlet_profiles",
383RUN_DIRECTORY_LAYOUT = (
386 "config/post-recipes",
389 "inputs/initial_condition",
390 "inputs/inlet_profiles",
393 "output/checkpoints",
395 "output/analysis/metrics",
396 "output/analysis/statistics",
397 "output/analysis/spectra",
398 "output/analysis/plots",
399 "output/visualization",
404CANONICAL_RUN_PATHS = {
407 "restart":
"inputs/restart",
409 "checkpoints":
"output/checkpoints",
410 "analysis":
"output/analysis",
411 "metrics":
"output/analysis/metrics",
412 "statistics":
"output/analysis/statistics",
413 "spectra":
"output/analysis/spectra",
414 "plots":
"output/analysis/plots",
415 "visualization":
"output/visualization",
417 "scheduler":
"scheduler",
422INITIAL_CONDITION_SPECTRUM_RELPATH = os.path.join(
423 CANONICAL_RUN_PATHS[
"spectra"],
"initial_condition_spectrum.csv"
426_WORKSPACE_ARTIFACT_ROOT_VALUES = {
"runs",
"studies"}
427_ASSET_SOURCE_REFERENCE_KEYS = {
428 "source_file",
"path",
"config_file",
"field_file",
"grid_file",
"source_case",
"script"
430_PYTHON_INITIAL_CONDITION_PROVIDERS = {
"ic_gen",
"spectral_random_velocity",
"resampled_velocity",
431 "channel_spectral_velocity",
"duct_spectral_velocity",
432 "stability_mode_velocity"}
434SPECTRAL_IC_PROVIDERS = (
"spectral_random_velocity",
"resampled_velocity",
435 "channel_spectral_velocity",
"duct_spectral_velocity",
"stability_mode_velocity")
437SHELL_SPECTRUM_IC_PROVIDERS = (
"spectral_random_velocity",
"resampled_velocity")
439SPECTRAL_RANDOM_VELOCITY_PARAMS = frozenset(
440 {
"field",
"seed",
"random",
"spectrum",
"projection",
"normalization",
"remove_mean"}
443RESAMPLED_VELOCITY_PARAMS = frozenset(
444 {
"field",
"source_file",
"format",
"cells",
"box_length",
"sample_offset",
"layout",
"filter",
445 "projection",
"remove_mean"}
448RESAMPLED_VELOCITY_FASTEST_AXES = (
"x",
"z")
449RESAMPLED_VELOCITY_COMPONENT_LAYOUTS = (
"interleaved",
"separate")
450RESAMPLED_VELOCITY_RAW_DTYPES = (
"float32",
"float64")
451RESAMPLED_VELOCITY_BYTE_ORDERS = (
"little",
"big",
"native")
452RESAMPLED_VELOCITY_PROJECTIONS = (
"picurv_discrete",
"continuum",
"none")
454RESAMPLED_VELOCITY_LAYOUT_KEYS = {
455 "raw": {
"fastest_axis",
"components",
"dtype",
"byte_order",
"header_bytes"},
456 "npy": {
"fastest_axis",
"components"},
457 "npz": {
"fastest_axis",
"components",
"datasets"},
458 "hdf5": {
"fastest_axis",
"components",
"datasets"},
460_FILE_BACKED_GRID_VALUES = {
"file",
"grid_gen"}
461_WORKSPACE_MANAGED_PATHS = {
"assets",
"inputs",
"runs",
"studies"}
462_VENDORABLE_CONFIG_REFERENCE_KEYS = {
"config_file",
"script"}
463_PLAIN_FILENAME_SENTINELS = {
"",
".",
".."}
464_VERSION_BUILD_ACTIONS = {
"install",
"activate"}
469 @brief Find a named file at or above an arbitrary filesystem anchor.
470 @param[in] start File or directory from which to search.
471 @param[in] filename Basename to locate.
472 @return Absolute path when found, otherwise None.
474 current = os.path.abspath(start
or os.getcwd())
475 if os.path.isfile(current):
476 current = os.path.dirname(current)
478 candidate = os.path.join(current, filename)
479 if os.path.isfile(candidate):
481 parent = os.path.dirname(current)
482 if parent == current:
489 @brief Locate the nearest initialized PICurv workspace for supplied anchors.
490 @param[in] anchors Candidate config, run, study, or current-working-directory paths.
491 @return Workspace root path when found, otherwise None.
493 for anchor
in anchors
or (os.getcwd(),):
498 return os.path.dirname(found)
504 @brief Load and validate the immutable workspace identity/configuration file.
505 @param[in] workspace_root Initialized workspace directory.
506 @return Parsed workspace configuration mapping.
508 path = os.path.join(os.path.abspath(workspace_root), WORKSPACE_CONFIG_FILENAME)
510 if payload.get(
"schema_version") != WORKSPACE_SCHEMA_VERSION:
512 f
"{path}: unsupported workspace schema_version "
513 f
"{payload.get('schema_version')!r}; expected {WORKSPACE_SCHEMA_VERSION}."
518 raise ValueError(
"\n".join(errors))
524 @brief Enforce an optional workspace PICurv version requirement.
525 @param[in] workspace_root Initialized workspace directory.
526 @return Current build identity after successful validation.
529 software = payload.get(
"software")
or {}
530 requirement = software.get(
"picurv")
if isinstance(software, dict)
else None
531 if requirement
in (
None,
""):
534 from packaging.specifiers
import SpecifierSet
535 from packaging.version
import Version
536 requirement_text = str(requirement).strip()
537 if not any(token
in requirement_text
for token
in "<>=!~"):
538 requirement_text =
"==" + requirement_text
539 matches = Version(PICURV_RELEASE_VERSION)
in SpecifierSet(requirement_text)
540 except Exception
as exc:
542 f
"{WORKSPACE_CONFIG_FILENAME}: software.picurv={requirement!r} is not a valid "
543 "version or version range."
547 f
"Workspace requires PICurv {requirement!r}, but the active release is "
548 f
"{PICURV_RELEASE_VERSION} (build {picurv_build()['build_id']}).\n"
549 f
"Activate a matching installation with 'picurv versions activate <version>' "
551 f
"Note: {PACKAGE_PROJECT_ROOT} is a single shared installation. Activating "
552 "re-points every workspace and every unpinned job that resolves executables "
553 "from it, so two workspaces pinned to different releases cannot both be "
554 "satisfied at once. A run staged with --pin-executables keeps its own copy and "
555 "survives an activation."
562 @brief Enforce an optional workspace policy demanding a clean, released build.
564 @details Exploratory work should stay frictionless, so this is opt-in: a workspace
565 running a campaign that will be published sets it once, and PICurv then
566 refuses to stage from a modified or untagged tree instead of recording the
567 compromise in a manifest nobody reads until later.
568 @param[in] workspace_root Initialized workspace directory, or None.
569 @return The resolved policy mapping, empty when none is configured.
570 @throws ValueError when the active build does not satisfy the configured policy.
572 if not workspace_root:
575 policy = payload.get(
"reproducibility")
or {}
576 if not isinstance(policy, dict)
or not policy:
578 if policy.get(
"require_clean_release"):
581 problems.append(
"the source tree has uncommitted changes")
584 f
"HEAD is {picurv_build()['dev_distance']} commit(s) past the "
585 f
"v{PICURV_RELEASE_VERSION} release tag"
588 problems.append(
"the release tag could not be resolved from this checkout")
591 f
"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.require_clean_release, "
592 f
"but this build is {picurv_build()['build_id']}: " +
"; ".join(problems) +
".\n"
593 "Commit and tag the release, or clear the policy for exploratory work."
595 if policy.get(
"pin_executables"):
598 name
for name, identity
in sorted(identities.items())
599 if identity.get(
"available")
and not identity.get(
"matches_source")
602 name
for name, identity
in sorted(identities.items())
603 if not identity.get(
"available")
605 if stale
or unavailable:
608 detail.append(
"built from another revision: " +
", ".join(stale))
610 detail.append(
"no build identity available: " +
", ".join(unavailable))
611 commands = sorted({
rebuild_command(name, identities[name])
for name
in stale + unavailable})
613 f
"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.pin_executables, but "
614 "the executables do not match the active source (" +
"; ".join(detail) +
").\n"
615 "Rebuild with " +
" and ".join(f
"'{command}'" for command
in commands)
616 +
" so the run records the build that produced it."
623 @brief Resolve a user path against its workspace and reject implicit escapes.
624 @param[in] anchor_file Config file whose workspace owns the reference.
625 @param[in] candidate Workspace-relative path text.
626 @param[in] allow_external Whether an explicit import/reference operation permits an absolute path.
627 @return Absolute resolved path.
629 if not isinstance(candidate, str)
or not candidate.strip():
630 raise ValueError(
"Referenced path must be a non-empty string.")
631 text = os.path.expanduser(candidate.strip())
633 if not workspace_root:
634 return os.path.abspath(text
if os.path.isabs(text)
else os.path.join(os.path.dirname(os.path.abspath(anchor_file)), text))
635 if os.path.isabs(text):
637 return os.path.abspath(text)
639 f
"{anchor_file}: absolute path {candidate!r} is not allowed in workspace configuration; "
640 "import it with 'picurv inputs import' or use an explicit reference-mode import."
642 resolved = os.path.abspath(os.path.join(workspace_root, text))
643 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
645 f
"{anchor_file}: path {candidate!r} escapes the workspace; parent traversal is not allowed."
647 if resolved.endswith(
".reference.yml")
and os.path.isfile(resolved):
649 external = pointer.get(
"picurv_external_reference")
if isinstance(pointer, dict)
else None
650 if not isinstance(external, str)
or not os.path.isabs(external):
651 raise ValueError(f
"Invalid external-reference descriptor: {resolved}")
652 if not os.path.isfile(external):
653 raise ValueError(f
"Registered external input is unavailable: {external}")
660 @brief Materialize the uniform, cheap directory skeleton for one workspace.
661 @param[in] workspace_root Workspace root to initialize.
663 for relative
in WORKSPACE_DIRECTORY_LAYOUT:
664 os.makedirs(os.path.join(workspace_root, *relative.split(
"/")), exist_ok=
True)
669 @brief Create the workspace skeleton and its identity file at one root.
670 @param[in] workspace_root Workspace root to initialize.
671 @param[in] template_name Example template the workspace was created from.
672 @return Path to the written workspace configuration file.
674 workspace_root = os.path.abspath(workspace_root)
676 config_path = os.path.join(workspace_root, WORKSPACE_CONFIG_FILENAME)
678 "schema_version": WORKSPACE_SCHEMA_VERSION,
680 "id": os.path.basename(workspace_root),
681 "template": template_name,
682 "created_at": datetime.now().astimezone().isoformat(),
690 "studies":
"studies",
698 @brief Materialize the uniform, cheap directory skeleton for one run.
699 @param[in] run_dir Run root to initialize.
701 for relative
in RUN_DIRECTORY_LAYOUT:
702 os.makedirs(os.path.join(run_dir, *relative.split(
"/")), exist_ok=
True)
707RUN_DIRECTORY_ROOTS = frozenset(
708 relative.split(
"/")[0]
for relative
in RUN_DIRECTORY_LAYOUT
713RUN_ROOT_ALLOWED_FILES = frozenset({
"manifest.json"})
718 @brief Refuse a run whose root has grown a directory the layout does not define.
720 @details The run topology is a contract: `manifest.json` publishes it, the solver's
721 reserved-directory guard defends it, and storage classifies against it. A
722 directory beside `output/` breaks all three at once - it is routed by
723 nothing, classified as `unclassified`, and therefore archived forever and
724 pruned never. Catching it when the run is staged or resumed is the only
725 point where the answer is still "move it", rather than "it is already in
726 every archive of this run".
728 Unexpected *files* are reported and allowed. A stray note at a run root
729 costs nothing and refusing to resume a long campaign over one would be a
730 worse failure than the one being prevented.
731 @param[in] run_dir Run root to check.
732 @return Tuple of (errors, warnings) as human-readable message lists.
734 root = os.path.abspath(run_dir)
735 if not os.path.isdir(root):
737 allowed_files = set(RUN_ROOT_ALLOWED_FILES) | {
738 STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME,
740 errors, warnings = [], []
741 for name
in sorted(os.listdir(root)):
742 path = os.path.join(root, name)
743 if os.path.isdir(path)
and not os.path.islink(path):
744 if name
not in RUN_DIRECTORY_ROOTS:
746 f
"{run_dir}: '{name}/' is not part of the run layout. Scientific "
747 f
"output belongs under {CANONICAL_RUN_PATHS['checkpoints']}, "
748 f
"{CANONICAL_RUN_PATHS['analysis']}, or "
749 f
"{CANONICAL_RUN_PATHS['visualization']}; move or remove '{name}/' "
750 f
"before running. Run-owned roots are: "
751 +
", ".join(sorted(RUN_DIRECTORY_ROOTS)) +
"."
753 elif name
not in allowed_files:
755 f
"{run_dir}: unexpected file '{name}' at the run root. It is archived "
756 "as unclassified and never pruned."
758 return errors, warnings
763 @brief Apply `validate_run_directory_structure()` as a refusal at run time.
764 @param[in] run_dir Run root to check.
765 @return None. Exits non-zero when the run root carries a directory outside the layout.
768 for message
in warnings:
769 print(f
"[WARNING] {message}", file=sys.stderr)
772 for message
in errors:
774 ERROR_CODE_CFG_INCONSISTENT_COMBO,
776 file_path=os.path.abspath(run_dir),
778 hint=
"Move the directory under output/ or out of the run, then retry.",
785 @brief Resolve the stable human-facing portion of a generated run identifier.
786 @param[in] case_cfg Parsed case configuration.
787 @param[in] case_path Source case YAML path.
788 @return Filesystem-safe case title, name, or source stem.
790 metadata = case_cfg.get(
"metadata")
or {}
792 case_cfg.get(
"title")
793 or (metadata.get(
"title")
if isinstance(metadata, dict)
else None)
794 or (metadata.get(
"name")
if isinstance(metadata, dict)
else None)
795 or Path(case_path).stem
797 label = re.sub(
r"[^A-Za-z0-9_.-]+",
"-", str(candidate).strip()).strip(
"-.")
798 return label
or "case"
803 @brief Build the generated run identity, disambiguating a same-second collision.
805 @details The identity is `<run label>_<timestamp>` at one-second resolution, so two
806 runs of the same case launched back to back - a script, or a smoke
807 sequence - would otherwise name the same directory. Creating a run never
808 writes into an existing one, so the identity is advanced instead of the
809 launch being refused for a clock artifact.
810 @param[in] runs_root Directory generated runs are created beneath.
811 @param[in] case_cfg Parsed case configuration supplying the run label.
812 @param[in] case_path Case path, for the label's fallback.
813 @return Run identity that does not currently exist under `runs_root`.
815 base = f
"{case_run_label(case_cfg, case_path)}_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
816 run_id, suffix = base, 1
817 while os.path.exists(os.path.join(runs_root, run_id)):
819 run_id = f
"{base}-{suffix}"
825 @brief Remove a generated run directory that never received any content.
827 @details Only a directory this invocation created, and which still holds no files,
828 is removed: an existing run, or one that already produced output, is never
829 touched by a staging failure. With @p staged the directory may hold the files
830 this invocation staged, for a refusal after staging but before any launch.
831 @param[in] run_dir Run directory that was about to be staged.
832 @param[in] created Whether this invocation created the directory.
833 @param[in] staged Whether files this invocation staged, and nothing else, may be present.
834 @return True when the directory was removed.
836 if not created
or not os.path.isdir(run_dir):
838 for _root, _dirs, files
in os.walk(run_dir):
839 if files
and not staged:
841 shutil.rmtree(run_dir, ignore_errors=
True)
847 @brief Content digest of one file, or None when it cannot be read.
848 @param[in] path File to digest.
849 @return Hex digest, or None.
851 digest = hashlib.sha256()
853 with open(path,
"rb")
as stream:
854 for block
in iter(
lambda: stream.read(1024 * 1024), b
""):
858 return digest.hexdigest()
863 @brief Capture the exact software identity a run is about to execute with.
865 @details The release and commit say which source is checked out; they do not say
866 which bytes ran. Hashing the executables and the generators pins that, so
867 a queued job rebuilt out from under it is detectable afterwards rather
868 than merely suspected.
869 @param[in] run_dir Run whose pinned executables are hashed; the installation's when None.
870 @return Software lock mapping.
874 "picurv_version": PICURV_RELEASE_VERSION,
878 "captured_at": datetime.now().astimezone().isoformat(),
883 for name
in RUN_EXECUTABLE_NAMES:
886 lock[
"executables"][name] = {
889 "build_id": identity.get(
"build_id"),
890 "matches_source": identity.get(
"matches_source"),
891 "petsc": identity.get(
"petsc"),
893 conductor = os.path.join(PACKAGE_PROJECT_ROOT,
"picurv_cli",
"core.py")
894 lock[
"python_conductor_sha256"] =
_file_sha256(conductor)
895 if os.path.isdir(GENERATORS_PATH):
896 for entry
in sorted(os.listdir(GENERATORS_PATH)):
897 candidate = os.path.join(GENERATORS_PATH, entry)
898 if os.path.isfile(candidate):
905 @brief Best-effort record of the PETSc, MPI, and compiler in the shell staging the run.
906 @details This is the staging environment, which need not be what the binaries were
907 built against; each executable's own PETSc is recorded from its build stamp.
908 @return Mapping of what could be determined; absent keys mean it could not be read.
911 petsc_dir = os.environ.get(
"PETSC_DIR")
913 identity[
"petsc_dir"] = petsc_dir
914 version_header = os.path.join(petsc_dir,
"include",
"petscversion.h")
916 with open(version_header,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
920 r"#define\s+PETSC_VERSION_(MAJOR|MINOR|SUBMINOR)\s+(\d+)", line
923 numbers[match.group(1)] = match.group(2)
924 if len(numbers) == 3:
925 identity[
"petsc_version"] = (
926 f
"{numbers['MAJOR']}.{numbers['MINOR']}.{numbers['SUBMINOR']}"
930 if os.environ.get(
"PETSC_ARCH"):
931 identity[
"petsc_arch"] = os.environ[
"PETSC_ARCH"]
932 for tool, key
in ((
"mpiexec",
"mpi"), (
"mpicc",
"compiler")):
933 executable = shutil.which(tool)
937 result = subprocess.run(
938 [executable,
"--version"], text=
True, capture_output=
True, timeout=20, check=
False
940 except (OSError, subprocess.SubprocessError):
942 first = (result.stdout
or result.stderr
or "").strip().splitlines()
944 identity[key] = first[0][:200]
950 @brief Write the run's software lock beside its asset lock.
951 @param[in] run_dir Run directory receiving the lock.
952 @return Path to the written lock.
954 path = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"inputs"],
"software.lock.json")
955 os.makedirs(os.path.dirname(path), exist_ok=
True)
962 @brief Return the canonical workspace-owned root for runs or studies.
963 @param[in] workspace_root Initialized workspace, or None for standalone mode.
964 @param[in] kind Either runs or studies.
965 @return Absolute artifact root.
967 if kind
not in _WORKSPACE_ARTIFACT_ROOT_VALUES:
968 raise ValueError(f
"Unsupported workspace artifact kind: {kind}")
970 return os.path.join(os.path.abspath(workspace_root), kind)
971 return os.path.abspath(kind)
976 @brief Return a portable workspace-relative path when possible.
977 @param[in] path Path to normalize.
978 @param[in] workspace_root Optional initialized workspace root.
979 @return Workspace-relative POSIX path, absolute path, or None.
983 absolute = os.path.abspath(path)
984 if workspace_root
and os.path.commonpath([absolute, workspace_root]) == os.path.abspath(workspace_root):
985 return os.path.relpath(absolute, workspace_root).replace(os.sep,
"/")
990 continuation: bool =
False) -> dict:
992 @brief Snapshot editable YAML inputs without erasing prior continuation state.
993 @param[in] run_dir Owning run directory.
994 @param[in] source_paths Role-to-source-path mapping.
995 @param[in] continuation Whether this is a continuation configuration revision.
996 @return Active snapshot metadata.
998 config_root = os.path.join(run_dir,
"config")
999 os.makedirs(config_root, exist_ok=
True)
1002 destination_root = config_root
1004 revision = datetime.now().strftime(
"%Y%m%d-%H%M%S-%f")
1005 destination_root = os.path.join(config_root,
"history", revision)
1006 os.makedirs(destination_root, exist_ok=
False)
1008 for role, source
in source_paths.items():
1011 source = os.path.abspath(source)
1012 suffix = os.path.splitext(source)[1]
or ".yml"
1013 name = f
"{role}{suffix}" if role !=
"cluster" else "cluster.yml"
1014 destination = os.path.join(destination_root, name)
1015 shutil.copy2(source, destination)
1016 snapshots[role] = os.path.relpath(destination, run_dir).replace(os.sep,
"/")
1018 "schema_version": 1,
1019 "revision": revision
or "initial",
1020 "updated_at": datetime.now().astimezone().isoformat(),
1023 if continuation
and isinstance(previous.get(
"executables"), dict):
1026 active[
"executables"] = previous[
"executables"]
1032CONTINUATION_STAGED_RECORDS = (
"config/active.json",
"manifest.json",
1033 "inputs/assets.lock.yml",
"inputs/software.lock.json")
1038 @brief Save what a continuation's staging changes, so a refusal can undo it.
1039 @details Staging rewrites the records in CONTINUATION_STAGED_RECORDS and materializes
1040 the restart checkpoint into the run's restart input, so both are captured
1041 before restart resolution runs.
1042 @param[in] run_dir Existing run directory.
1043 @param[in] restart_dir The run's restart input directory.
1044 @return Saved state for restore_continuation_records().
1047 for relative
in CONTINUATION_STAGED_RECORDS:
1048 path = os.path.join(run_dir, *relative.split(
"/"))
1049 records[relative] = open(path,
"rb").read()
if os.path.isfile(path)
else None
1050 checkpoints = os.path.join(restart_dir,
"checkpoints")
1051 return {
"records": records,
"checkpoints": checkpoints,
1052 "checkpoint_entries": set(os.listdir(checkpoints))
if os.path.isdir(checkpoints)
else None}
1057 @brief Undo a refused continuation's staging: restore its records, drop its revision.
1058 @details Removes the restart checkpoints staging materialized; everything else a
1059 continuation stages, including pinned executables, lives in the revision
1060 directory, so removing it leaves the run as it was.
1061 @param[in] run_dir Existing run directory.
1062 @param[in] saved State returned by capture_continuation_records().
1063 @param[in] revision Configuration revision the refused staging created.
1065 checkpoints, before = saved[
"checkpoints"], saved[
"checkpoint_entries"]
1066 if os.path.isdir(checkpoints):
1067 for entry
in set(os.listdir(checkpoints)) - (before
or set()):
1068 shutil.rmtree(os.path.join(checkpoints, entry), ignore_errors=
True)
1069 if before
is None and not os.listdir(checkpoints):
1070 os.rmdir(checkpoints)
1071 for relative, content
in saved[
"records"].items():
1072 path = os.path.join(run_dir, *relative.split(
"/"))
1074 if os.path.isfile(path):
1077 with open(path,
"wb")
as stream:
1078 stream.write(content)
1079 shutil.rmtree(os.path.join(run_dir,
"config",
"history", revision), ignore_errors=
True)
1084 @brief Load the active immutable configuration revision for a run.
1085 @param[in] run_dir Run directory.
1086 @return Role-to-absolute-path mapping.
1088 config_root = os.path.join(os.path.abspath(run_dir),
"config")
1090 if isinstance(active, dict)
and isinstance(active.get(
"files"), dict):
1092 role: os.path.join(run_dir, *relative.split(
"/"))
1093 for role, relative
in active[
"files"].items()
1094 if isinstance(relative, str)
1097 role: os.path.join(config_root, f
"{role}.yml")
1098 for role
in (
"case",
"solver",
"monitor",
"cluster")
1099 if os.path.isfile(os.path.join(config_root, f
"{role}.yml"))
1104RUN_EXECUTABLE_NAMES = (
"simulator",
"postprocessor")
1109 @brief Return the executables a run is pinned to, as recorded by its active configuration.
1110 @param[in] run_dir Run directory.
1111 @return Mapping of executable name to its recorded entry, with `path` made absolute;
1112 empty when the run launches the installation's executables.
1115 recorded = active.get(
"executables")
if isinstance(active, dict)
else None
1116 if not isinstance(recorded, dict):
1119 for name, entry
in recorded.items():
1120 if isinstance(entry, dict)
and isinstance(entry.get(
"path"), str):
1121 pinned[name] = dict(entry, path=os.path.join(os.path.abspath(run_dir), *entry[
"path"].split(
"/")))
1127 @brief Resolve the executable a run launches: its pinned copy, else the installation's.
1128 @param[in] run_dir Run directory, or None when no run exists yet.
1129 @param[in] name `simulator` or `postprocessor`.
1130 @return Absolute executable path.
1131 @throws ValueError when the run records a pinned copy that is no longer on disk.
1136 if not os.path.isfile(pinned[
"path"]):
1138 f
"{run_dir} is pinned to {pinned['path']}, which no longer exists. Restore it "
1139 "from the run's archive, or re-pin with --pin-executables to run the current build."
1141 return pinned[
"path"]
1146 @brief Copy the installation's executables into a run's configuration revision.
1148 @details The installation's `bin/` is rebuilt in place, so a job that resolves its
1149 executable there when it starts runs whatever was built last, not what was
1150 staged. The copy lives under `config/`, which storage retains as essential
1151 and the solver refuses to write into, and `active.json` records it so every
1152 later stage of the run - continuation, post-processing - launches the same
1153 build. Each copy is written beside its destination and renamed into place.
1154 @param[in] run_dir Run directory.
1155 @param[in] revision Active configuration revision (`initial` or a continuation id).
1156 @return Recorded executables mapping, keyed by executable name; an executable that
1157 has not been built is left unpinned with a warning, as staging treats it today.
1159 run_root = os.path.abspath(run_dir)
1160 config_root = os.path.join(run_root,
"config")
1161 target = (os.path.join(config_root,
"bin")
if revision ==
"initial"
1162 else os.path.join(config_root,
"history", revision,
"bin"))
1164 for name
in RUN_EXECUTABLE_NAMES:
1166 if not os.path.isfile(source):
1167 print(f
"[WARNING] {name} is not built at {source}, so it is not pinned; a stage that "
1168 "launches it will use whatever is there when it starts.", file=sys.stderr)
1170 os.makedirs(target, exist_ok=
True)
1171 destination = os.path.join(target, name)
1172 partial = destination +
".partial"
1173 shutil.copy2(source, partial)
1174 os.replace(partial, destination)
1177 "path": os.path.relpath(destination, run_root).replace(os.sep,
"/"),
1178 "pinned_from": source,
1180 "build_id": identity.get(
"build_id"),
1181 "version_line": identity.get(
"version_line"),
1182 "identity_reason":
None if identity.get(
"available")
else identity.get(
"reason"),
1186 active_path = os.path.join(config_root,
"active.json")
1187 active =
_read_json_if_exists(active_path)
or {
"schema_version": 1,
"revision": revision,
"files": {}}
1188 active[
"executables"] = recorded
1189 active[
"updated_at"] = datetime.now().astimezone().isoformat()
1196 @brief Predict, without writing, which executables staging would give a run.
1197 @details Mirrors `apply_run_executable_pinning()` for `run --dry-run`: an existing
1198 pin is kept unless re-pinned, a new pin lands in the configuration revision
1199 staging would create, and otherwise the installation's executables run.
1200 @param[in] run_dir Run directory the plan targets.
1201 @param[in] args Parsed command arguments.
1202 @param[in] workspace_root Owning workspace, or None.
1203 @return Mapping with `pinning` (`kept`, `new`, or `none`) and per-executable `paths`.
1206 if existing
and getattr(args,
"pin_executables",
None)
is not True:
1207 return {
"pinning":
"kept",
"paths": {name: entry[
"path"]
for name, entry
in existing.items()}}
1209 revision_root = (os.path.join(run_dir,
"config")
if not existing
1210 and not getattr(args,
"continue_run",
False)
1211 else os.path.join(run_dir,
"config",
"history",
"<new-revision>"))
1212 return {
"pinning":
"new",
1213 "paths": {name: os.path.join(revision_root,
"bin", name)
for name
in RUN_EXECUTABLE_NAMES}}
1214 return {
"pinning":
"none",
1220 @brief Decide whether staging pins the installation's executables into the run.
1221 @details Pinning is opt-in: an explicit `--pin-executables`/`--no-pin-executables`
1222 wins, then the workspace's `reproducibility.pin_executables`. Unpinned jobs
1223 are still protected by the job-start identity check, which stops a job whose
1224 executable was rebuilt after staging instead of running the new build.
1225 @param[in] args Parsed command arguments.
1226 @param[in] workspace_root Owning workspace, or None.
1227 @return True to pin, False not to.
1229 explicit = getattr(args,
"pin_executables",
None)
1230 if explicit
is not None:
1231 return bool(explicit)
1234 if isinstance(policy, dict):
1235 return bool(policy.get(
"pin_executables"))
1240 workspace_root:
"str | None") -> dict:
1242 @brief Pin, keep, or refuse the run's executables before anything records them.
1243 @details A run already pinned keeps its build unless the caller explicitly re-pins,
1244 so a continuation or post-processing pass cannot silently switch builds.
1245 Refusing `--no-pin-executables` on a pinned run keeps the run's records
1246 describing one build rather than a pin nobody launched.
1247 @param[in] run_dir Run directory.
1248 @param[in] revision Active configuration revision.
1249 @param[in] args Parsed command arguments.
1250 @param[in] workspace_root Owning workspace, or None.
1251 @return The run's pinned executables; empty when it launches the installation's.
1252 @throws ValueError when `--no-pin-executables` is given for a pinned run.
1254 explicit = getattr(args,
"pin_executables",
None)
1256 if existing
and explicit
is not True:
1257 if explicit
is False:
1258 build = existing.get(
"simulator", {}).get(
"build_id")
or "a pinned build"
1260 f
"{run_dir} is pinned to {build}. --no-pin-executables would launch the "
1261 "installation's build instead while the run still records the pin; re-pin "
1262 "with --pin-executables to move this run to the current build."
1264 build = existing.get(
"simulator", {}).get(
"build_id")
or "unknown build"
1265 print(f
"[INFO] Launching the run's pinned executables ({build}).")
1272 first = next(iter(recorded.values()))
1273 build = first.get(
"build_id")
or "build identity unavailable"
1274 print(f
"[INFO] Pinned {', '.join(sorted(recorded))} into "
1275 f
"{os.path.dirname(first['path'])}/ ({build}).")
1281 @brief Preserve generated control sidecars beside a continuation's YAML revision.
1282 @param[in] run_dir Owning run.
1283 @param[in] paths Generated control and sidecar paths.
1284 @return Updated active configuration record.
1286 config_root = os.path.join(os.path.abspath(run_dir),
"config")
1287 active_path = os.path.join(config_root,
"active.json")
1288 active =
_read_json_if_exists(active_path)
or {
"schema_version": 1,
"revision":
"initial",
"files": {}}
1289 revision = active.get(
"revision",
"initial")
1290 target_root = config_root
if revision ==
"initial" else os.path.join(config_root,
"history", revision)
1291 os.makedirs(target_root, exist_ok=
True)
1292 files = active.setdefault(
"files", {})
1293 for source
in paths:
1294 if not source
or not os.path.isfile(source):
1296 source = os.path.abspath(source)
1297 destination = os.path.join(target_root, os.path.basename(source))
1298 if source != destination:
1299 shutil.copy2(source, destination)
1300 relative = os.path.relpath(destination, run_dir).replace(os.sep,
"/")
1301 if destination.endswith(
".control"):
1302 files[
"control"] = relative
1304 files.setdefault(
"generated", []).append(relative)
1305 active[
"updated_at"] = datetime.now().astimezone().isoformat()
1312STORAGE_OFFLOADED_STATES = frozenset({
"COLD",
"PARTIAL"})
1315RUN_COMPONENT_STATES = (
1316 "not_requested",
"planned",
"running",
"partial",
"complete",
"failed",
"offloaded",
1320RUN_COMPONENT_LAYOUT = (
1321 (
"configuration",
"config",
"essential"),
1322 (
"inputs",
"inputs",
"essential"),
1323 (
"checkpoints",
"output/checkpoints",
"policy"),
1324 (
"analysis",
"output/analysis",
"derived"),
1325 (
"field_statistics",
"output/analysis/statistics",
"derived"),
1326 (
"spectra",
"output/analysis/spectra",
"derived"),
1327 (
"visualization",
"output/visualization",
"derived"),
1328 (
"logs",
"logs",
"essential"),
1329 (
"scheduler",
"scheduler",
"essential"),
1335 @brief Report each run component's home, retention class, and lifecycle state.
1337 @details The skeleton is created whole, so an empty directory says nothing about
1338 whether its contents were requested. This is where that question is
1339 answered, and it is answered for every component whether or not it ran.
1340 @param[in] run_dir Run directory being described.
1341 @param[in] stages_requested Mapping of requested stage names to booleans.
1342 @return Component name to `{path, retention, state}` mapping.
1344 marker = storage_state_summary(run_dir)
if os.path.isdir(run_dir)
else {
"state":
"LOCAL"}
1345 offloaded = marker.get(
"state")
in STORAGE_OFFLOADED_STATES
1346 solve_requested = bool(stages_requested.get(
"solve"))
1347 post_requested = bool(stages_requested.get(
"post_process"))
1349 "configuration":
True,
1353 "checkpoints": solve_requested,
1354 "analysis": post_requested,
1355 "field_statistics": post_requested,
1356 "spectra": post_requested,
1357 "visualization": post_requested,
1360 for name, relative, retention
in RUN_COMPONENT_LAYOUT:
1361 path = os.path.join(run_dir, *relative.split(
"/"))
1363 if os.path.isdir(path):
1364 populated = any(files
for _root, _dirs, files
in os.walk(path))
1366 state =
"offloaded" if offloaded
and retention !=
"essential" else "complete"
1367 elif not requested.get(name,
False):
1368 state =
"not_requested"
1373 components[name] = {
"path": relative,
"retention": retention,
"state": state}
1378 statistics_state=
None, requested_source=
None) -> dict:
1380 @brief Record which run and which checkpoint a branched run was started from.
1382 @details Without this a branch is indistinguishable from a fresh run: the copied
1383 bundle under `inputs/restart/` carries the parent's geometry and software
1384 identity but never says which run produced it, so the trajectory a result
1385 belongs to cannot be reconstructed after the fact.
1387 The parent's own manifest is asked for its identity rather than its
1388 directory name, so a parent that was renamed or restored under another
1389 name is still named correctly here.
1390 @param[in] parent_run_dir Absolute path to the run being branched from.
1391 @param[in] checkpoint_step Checkpoint step the branch starts from.
1392 @param[in] workspace_root Optional workspace root, to record a portable path.
1393 @param[in] statistics_state Resolved field-statistics decision for the branch.
1394 @param[in] requested_source What the user asked for, e.g. "latest" or a path.
1395 @return JSON-serializable lineage record.
1397 parent_identity = read_artifact_identity(parent_run_dir)
1399 "relationship":
"branch",
1400 "parent_run_id": parent_identity[
"run_id"],
1401 "parent_study_id": parent_identity[
"study_id"]
if parent_identity[
"case_id"]
else None,
1402 "parent_case_id": parent_identity[
"case_id"],
1403 "parent_identity_source": parent_identity[
"identity_source"],
1405 "checkpoint_step": int(checkpoint_step),
1406 "statistics_state": statistics_state,
1407 "requested_source": requested_source,
1408 "recorded_at": datetime.now().astimezone().isoformat(),
1413 launch_mode: str =
"local", num_procs: int = 1,
1414 post_num_procs: int = 1, stages_requested=
None,
1415 stages_completed=
None, inputs=
None, asset_lock=
None,
1416 submission=
None, lineage=
None, artifact_type: str =
"run",
1417 study_id=
None, case_id=
None) -> dict:
1419 @brief Build the authoritative run identity, topology, and lifecycle manifest.
1420 @param[in] run_dir Owning run directory.
1421 @param[in] run_id Stable run identity.
1422 @param[in] workspace_root Optional initialized workspace root.
1423 @param[in] launch_mode Local or scheduler-backed launch mode.
1424 @param[in] num_procs Effective solver process count.
1425 @param[in] post_num_procs Effective post-process count.
1426 @param[in] stages_requested Requested stage mapping.
1427 @param[in] stages_completed Completed or submitted stage names.
1428 @param[in] inputs Active configuration identities.
1429 @param[in] asset_lock Resolved run asset lock.
1430 @param[in] submission Scheduler submission metadata.
1431 @param[in] lineage Where this run's initial state came from; see `build_run_lineage()`.
1432 @param[in] artifact_type "run" for a standalone run, "study-case" for a study member.
1433 @param[in] study_id Owning study identity, for a study member.
1434 @param[in] case_id Member identity within its study.
1435 @return JSON-serializable run manifest.
1438 created_at = existing.get(
"created_at")
or datetime.now().astimezone().isoformat()
1441 requested_now = dict(stages_requested
or {})
1442 stages_requested = {stage: bool(requested_now.get(stage)
or (existing.get(
"stages_requested")
or {}).get(stage))
1443 for stage
in {**(existing.get(
"stages_requested")
or {}), **requested_now}}
1444 stages_completed =
list(dict.fromkeys(
1445 list(existing.get(
"stages_completed_or_submitted")
or []) +
list(stages_completed
or [])))
1446 if not requested_now.get(
"solve")
and "solver_num_procs" in existing:
1447 num_procs = existing[
"solver_num_procs"]
1448 if not requested_now.get(
"post_process")
and "post_num_procs" in existing:
1449 post_num_procs = existing[
"post_num_procs"]
1451 "schema_version": RUN_MANIFEST_SCHEMA_VERSION,
1452 "artifact_type": artifact_type,
1454 "study_id": study_id,
1456 "created_at": created_at,
1457 "updated_at": datetime.now().astimezone().isoformat(),
1460 if workspace_root
else None
1467 "launch_mode": launch_mode,
1468 "num_procs": num_procs,
1469 "solver_num_procs": num_procs,
1470 "post_num_procs": post_num_procs,
1471 "stages_requested": stages_requested
or {},
1472 "stages_completed_or_submitted": stages_completed
or [],
1473 "inputs": inputs
or {},
1474 "paths": dict(CANONICAL_RUN_PATHS),
1477 "root_path": os.path.abspath(run_dir),
1482 "assets": (asset_lock
or {}).get(
"assets", {}),
1483 "runtime_providers": (asset_lock
or {}).get(
"runtime_providers", {}),
1488 "lineage": lineage
or existing.get(
"lineage")
or {
"relationship":
"root"},
1489 "submission": submission
or {},
1496 @brief Return the location a run's generated files refer to.
1497 @details Runs staged before the manifest recorded `root_path` are read from the
1498 active control file, whose generated input paths all start at the run root.
1499 @param[in] run_dir Current run directory.
1500 @return Recorded absolute root, or None when it cannot be determined.
1503 if manifest.get(
"root_path"):
1504 return manifest[
"root_path"]
1506 if not (control
and os.path.isfile(control)):
1508 markers = tuple(f
"/{CANONICAL_RUN_PATHS[key]}/" for key
in (
"config",
"inputs"))
1509 with open(control,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
1511 for token
in line.split()[1:]:
1512 for marker
in markers:
1513 if not (token.startswith(
"/")
and marker
in token):
1515 candidate, rest = token.split(marker, 1)
1518 if os.path.exists(os.path.join(run_dir, marker.strip(
"/"), rest)):
1525 @brief Point a moved run's or study's generated files at where it now lives.
1526 @details Generated control files, recipes, scheduler scripts, and case indexes hold
1527 absolute paths, so a run or study moved or copied to another directory would
1528 otherwise launch against its old location. The rewrite is the one a
1529 relocated storage restore applies, after which the recorded root is updated.
1530 @param[in] root Current run or study directory.
1531 @param[in] kind "run" or "study".
1532 @param[in] dry_run Report the relocation without rewriting anything.
1533 @return Number of files rewritten (0 when the artifact has not moved).
1535 root = os.path.abspath(root)
1537 manifest_path = os.path.join(root,
"study_manifest.json")
1540 manifest_path = os.path.join(root,
"manifest.json")
1542 if not recorded
or os.path.realpath(recorded) == os.path.realpath(root)
or recorded == root:
1545 print(f
"[WARNING] This {kind} was moved from {recorded}; a real invocation first updates "
1546 "its generated files to the new location.", file=sys.stderr)
1548 changed = _rebase_restored_text_paths(root, [(recorded, root)])
1550 if isinstance(manifest, dict):
1552 manifest.setdefault(
"paths", {})[
"study_dir"] = root
1554 manifest[
"root_path"] = root
1556 print(f
"[INFO] This {kind} was moved from {recorded}; updated {len(changed)} generated "
1557 "file(s) to its new location.")
1563 @brief Resolve a run/output root or an exact checkpoint bundle.
1564 @param[in] source_dir Run/output root or exact bundle path.
1565 @param[in] step Checkpoint step to resolve.
1566 @return Absolute path to the checkpoint bundle.
1568 source_dir = os.path.abspath(source_dir)
1569 if os.path.isfile(os.path.join(source_dir,
"checkpoint.meta")):
1571 return os.path.join(source_dir,
"checkpoints", f
"step_{step:0{CHECKPOINT_STEP_WIDTH}d}")
1576 @brief Parse the deliberately small PETSc-options checkpoint manifest.
1577 @param[in] metadata_path Path to checkpoint.meta.
1578 @return Mapping of option names to scalar text values.
1581 with open(metadata_path,
"r", encoding=
"utf-8")
as stream:
1582 for line_number, raw_line
in enumerate(stream, start=1):
1583 tokens = shlex.split(raw_line, comments=
True, posix=
True)
1586 if len(tokens) != 2
or not tokens[0].startswith(
"-"):
1588 f
"Invalid checkpoint metadata line {line_number} in {metadata_path}."
1592 raise ValueError(f
"Duplicate checkpoint metadata key '-{key}' in {metadata_path}.")
1593 options[key] = tokens[1]
1599 @brief A committed checkpoint written by a PETSc of the other index width than the driven solver's.
1600 @details Raised instead of a plain ValueError so a scan for usable checkpoints reports it
1601 rather than skipping the checkpoint as if it were incomplete.
1607 @brief Validate one committed bundle using the same manifest contract as C.
1608 @param[in] source_dir Run/output root or exact bundle path.
1609 @param[in] step Expected checkpoint step.
1610 @param[in] require_particles Whether particle restart payloads are required.
1611 @return Validated bundle path, metadata, and payload inventory.
1614 metadata_path = os.path.join(bundle,
"checkpoint.meta")
1615 commit_path = os.path.join(bundle,
"COMMITTED")
1616 if not os.path.isfile(metadata_path)
or not os.path.isfile(commit_path):
1617 raise ValueError(f
"No committed checkpoint for step {step}: {bundle}")
1619 with open(commit_path,
"r", encoding=
"ascii")
as stream:
1620 expected_digest = stream.read().strip()
1621 if not re.fullmatch(
r"[0-9a-fA-F]{64}", expected_digest):
1622 raise ValueError(f
"Invalid checkpoint commit marker: {commit_path}")
1623 with open(metadata_path,
"rb")
as stream:
1624 actual_digest = hashlib.sha256(stream.read()).hexdigest()
1625 if actual_digest.lower() != expected_digest.lower():
1626 raise ValueError(f
"Checkpoint metadata hash mismatch: {bundle}")
1630 saved_version = int(options.get(
"checkpoint_version",
"-1"))
1631 saved_step = int(options.get(
"checkpoint_step",
"-1"))
1632 payload_count = int(options.get(
"checkpoint_payload_count",
"-1"))
1633 particle_count = int(options.get(
"checkpoint_particle_count",
"-1"))
1634 block_count = int(options.get(
"checkpoint_block_count",
"-1"))
1635 float(options[
"checkpoint_time"])
1636 except (KeyError, TypeError, ValueError)
as exc:
1637 raise ValueError(f
"Checkpoint metadata is incomplete or malformed: {metadata_path}")
from exc
1638 if options.get(
"checkpoint_format") != CHECKPOINT_FORMAT
or saved_version != CHECKPOINT_VERSION:
1639 raise ValueError(f
"Unsupported checkpoint format/version: {metadata_path}")
1640 if saved_step != step:
1641 raise ValueError(f
"Checkpoint records step {saved_step}, expected {step}: {bundle}")
1644 saved_index_bytes = int(options.get(
"checkpoint_petsc_index_bytes",
"4"))
1645 solver_index_bytes = _SELECTED_PETSC_INDEX_BYTES
or saved_index_bytes
1646 if saved_index_bytes != solver_index_bytes:
1647 raise CheckpointIndexWidthError(
1648 f
"Checkpoint {bundle} was written by a PETSc with {8 * saved_index_bytes}-bit indices, but this "
1649 f
"picurv drives a {8 * solver_index_bytes}-bit-index build ({resolve_runtime_executable('simulator')}). "
1650 f
"Restart or post-process it with the PICurv built on a {8 * saved_index_bytes}-bit-index PETSc.")
1651 if payload_count <= 0
or particle_count < 0
or block_count <= 0:
1652 raise ValueError(f
"Checkpoint inventory counts are invalid: {metadata_path}")
1655 for index
in range(payload_count):
1656 prefix = f
"checkpoint_payload_{index}_"
1657 relative_path = options.get(prefix +
"path")
1659 expected_bytes = int(options[prefix +
"bytes"])
1660 except (KeyError, TypeError, ValueError)
as exc:
1661 raise ValueError(f
"Checkpoint payload {index} has invalid metadata: {metadata_path}")
from exc
1662 if not relative_path
or os.path.isabs(relative_path)
or ".." in relative_path.split(
"/"):
1663 raise ValueError(f
"Checkpoint payload {index} has an unsafe path: {relative_path!r}")
1664 payload_path = os.path.join(bundle, *relative_path.split(
"/"))
1665 if not os.path.isfile(payload_path)
or os.path.getsize(payload_path) != expected_bytes:
1666 raise ValueError(f
"Checkpoint payload is missing or truncated: {payload_path}")
1668 "path": relative_path,
1669 "kind": options.get(prefix +
"kind"),
1670 "field": options.get(prefix +
"field"),
1671 "block": options.get(prefix +
"block"),
1674 for block
in range(block_count):
1676 item[
"field"]
for item
in payloads
1677 if item[
"kind"] ==
"eulerian" and item[
"block"] == str(block)
1679 missing = CHECKPOINT_REQUIRED_EULERIAN_FIELDS - fields
1682 f
"Checkpoint block {block} is missing required Eulerian field(s): "
1683 f
"{', '.join(sorted(missing))}."
1685 particle_payloads = [item
for item
in payloads
if item[
"kind"] ==
"particle"]
1686 has_particles = options.get(
"checkpoint_particles",
"false").lower() ==
"true"
1687 if (
not has_particles
and particle_count != 0)
or (has_particles
and not particle_payloads):
1688 raise ValueError(f
"Checkpoint particle inventory is inconsistent: {metadata_path}")
1689 if require_particles
and not has_particles:
1690 raise ValueError(f
"Checkpoint step {step} does not contain particle state: {bundle}")
1694 "metadata": options,
1695 "payloads": payloads,
1696 "has_particles": has_particles,
1697 "particle_count": particle_count,
1703 @brief Return only fully validated, committed checkpoint steps.
1704 @param[in] source_dir Run/output root containing checkpoints.
1705 @param[in] require_particles Whether particle restart payloads are required.
1706 @return Set of valid committed step numbers.
1708 checkpoints_dir = os.path.join(os.path.abspath(source_dir),
"checkpoints")
1709 if not os.path.isdir(checkpoints_dir):
1711 pattern = re.compile(rf
"^step_(\d{{{CHECKPOINT_STEP_WIDTH}}})$")
1713 for name
in os.listdir(checkpoints_dir):
1714 match = pattern.fullmatch(name)
1717 step = int(match.group(1))
1720 except CheckpointIndexWidthError:
1730 @brief Parse a Slurm time-limit string into total seconds.
1731 @param[in] time_text Argument passed to `parse_slurm_time_limit_to_seconds()`.
1732 @return Value returned by `parse_slurm_time_limit_to_seconds()`.
1734 text = str(time_text).strip()
1736 raise ValueError(
"time limit cannot be empty")
1741 day_text, clock_text = text.split(
"-", 1)
1742 if not day_text.isdigit():
1743 raise ValueError(f
"invalid day field '{day_text}'")
1744 days = int(day_text)
1746 raise ValueError(
"missing time portion after day field")
1748 parts = clock_text.split(
":")
1750 raise ValueError(f
"unsupported time format '{time_text}'")
1751 if any(part ==
"" for part
in parts):
1752 raise ValueError(f
"malformed time field '{time_text}'")
1753 if any(
not part.isdigit()
for part
in parts):
1754 raise ValueError(f
"non-numeric time field '{time_text}'")
1756 nums = [int(part)
for part
in parts]
1759 hours, minutes, seconds = nums[0], 0, 0
1760 elif len(nums) == 2:
1761 hours, minutes = nums
1764 hours, minutes, seconds = nums
1767 hours, minutes, seconds = 0, nums[0], 0
1768 elif len(nums) == 2:
1770 minutes, seconds = nums
1772 hours, minutes, seconds = nums
1774 if minutes >= 60
or seconds >= 60:
1775 raise ValueError(f
"minutes and seconds must be < 60 in '{time_text}'")
1776 if days == 0
and len(nums) == 3
and hours < 0:
1777 raise ValueError(f
"hours must be non-negative in '{time_text}'")
1779 total_seconds = (((days * 24) + hours) * 60 + minutes) * 60 + seconds
1780 if total_seconds <= 0:
1781 raise ValueError(
"time limit must be positive")
1782 return total_seconds
1787 @brief Resolve the effective Slurm walltime-guard policy for generated solver jobs.
1788 @param[in] cluster_cfg Argument passed to `resolve_walltime_guard_policy()`.
1789 @return Value returned by `resolve_walltime_guard_policy()`.
1791 if not isinstance(cluster_cfg, dict):
1794 scheduler = cluster_cfg.get(
"scheduler", {})
or {}
1795 if str(scheduler.get(
"type",
"slurm")).lower() !=
"slurm":
1798 execution = cluster_cfg.get(
"execution", {})
or {}
1799 guard_cfg = execution.get(
"walltime_guard")
1800 if guard_cfg
is None:
1802 elif not isinstance(guard_cfg, dict):
1803 raise ValueError(
"execution.walltime_guard must be a mapping when provided")
1805 policy = copy.deepcopy(DEFAULT_WALLTIME_GUARD_POLICY)
1806 policy.update(guard_cfg)
1807 policy[
"enabled"] = bool(policy[
"enabled"])
1808 policy[
"warmup_steps"] = int(policy[
"warmup_steps"])
1809 policy[
"multiplier"] = float(policy[
"multiplier"])
1810 policy[
"min_seconds"] = float(policy[
"min_seconds"])
1811 policy[
"estimator_alpha"] = float(policy[
"estimator_alpha"])
1817 @brief Build shell-evaluated environment exports for the runtime walltime guard.
1818 @param[in] cluster_cfg Argument passed to `build_walltime_guard_exports()`.
1819 @return Value returned by `build_walltime_guard_exports()`.
1822 if not policy
or not policy.get(
"enabled",
False):
1826 WALLTIME_GUARD_ENV_JOB_START_EPOCH:
"$(date +%s)",
1827 WALLTIME_GUARD_ENV_LIMIT_SECONDS: str(walltime_limit_seconds),
1832_SELECTED_PETSC_INDEX_BYTES =
None
1837 @brief Resolve solver/post executable path, preferring local sibling binaries.
1838 @details A command that selected the 64-bit-index build resolves `simulator64` and
1839 `postprocessor64`, which `make INDEX_BITS=64` installs beside the 32-bit pair.
1840 @param[in] executable_name `simulator` or `postprocessor`.
1841 @return Absolute executable path.
1843 if _SELECTED_PETSC_INDEX_BYTES == 8
and executable_name
in RUN_EXECUTABLE_NAMES:
1844 executable_name +=
"64"
1845 local_candidate = os.path.join(INVOKED_SCRIPT_DIR, executable_name)
1846 if os.path.isfile(local_candidate):
1847 return os.path.abspath(local_candidate)
1848 return os.path.join(DEFAULT_BIN_DIR, executable_name)
1853_BINARY_VERSION_PATTERN = re.compile(
1854 r"^(?P<name>\S+)\s+(?P<version>(?P<release>[^+\s]+?)(?:\.dev\d+)?)\+g(?P<commit>[0-9a-f]+)(?P<dirty>\.dirty)?\s*$"
1857_BINARY_PETSC_PATTERN = re.compile(
1858 r"^petsc (?P<version>\S+) (?P<mode>debug|optimized)(?: (?P<indices>int32|int64))? (?P<arch>\S*) (?P<dir>.*)$"
1861PETSC_INDEX_BYTES = {
"int32": 4,
"int64": 8}
1863_BINARY_PETSC_STAMP_MARKER = b
"PICURV_PETSC_BUILD:"
1865_BINARY_BUILD_STAMP_MARKER = b
"PICURV_BUILD:"
1870 @brief Parse the PETSc build line a native executable reports.
1871 @param[in] line `petsc <version> <debug|optimized> [int32|int64] <arch> <dir>`.
1872 @return Mapping with version, mode, indices, arch, and dir, or None when the line is not
1873 one. A binary built before the index width was stamped is 32-bit; `indices_stamped`
1874 records whether the line named it, so the line can be rebuilt exactly.
1876 match = _BINARY_PETSC_PATTERN.match((line
or "").strip())
1879 parsed = {key: match.group(key)
for key
in (
"version",
"mode",
"arch",
"dir")}
1880 parsed[
"indices_stamped"] = match.group(
"indices")
is not None
1881 parsed[
"indices"] = match.group(
"indices")
or "int32"
1887 @brief Read the PETSc build stamp from an executable's bytes, without running it.
1888 @details A binary built against one PETSc and started against another cannot run
1889 `--version` at all, which is exactly when knowing its PETSc matters.
1890 @param[in] executable_path Path to `simulator` or `postprocessor`.
1891 @return The parsed stamp, or None when the file carries none (built before it existed).
1894 with open(executable_path,
"rb")
as stream:
1895 data = stream.read()
1898 start = data.find(_BINARY_PETSC_STAMP_MARKER)
1901 start += len(_BINARY_PETSC_STAMP_MARKER)
1902 text = data[start:start + 4096].split(b
"\0", 1)[0].split(b
"\n", 1)[0]
1908 @brief The PETSc shared library the dynamic loader would give this executable when picurv launches it.
1909 @details Uses `ldd` on the current shell's library path with the executable's own PETSc
1910 lib/ first, as launches and job scripts put it; returns None where `ldd` is
1911 unavailable or the executable links PETSc statically.
1912 @param[in] executable_path Path to `simulator` or `postprocessor`.
1913 @return Absolute library path, `not found`, or None.
1915 ldd = shutil.which(
"ldd")
1918 env = dict(os.environ)
1921 env[
"LD_LIBRARY_PATH"] = os.pathsep.join(filter(
None, (library_dir, env.get(
"LD_LIBRARY_PATH"))))
1923 result = subprocess.run([ldd, executable_path], text=
True, capture_output=
True,
1924 timeout=20, check=
False, env=env)
1925 except (OSError, subprocess.SubprocessError):
1927 for line
in (result.stdout
or "").splitlines():
1928 name, _, target = line.strip().partition(
"=>")
1929 if not name.strip().startswith(
"libpetsc"):
1931 target = target.strip()
1932 if target.startswith(
"not found"):
1934 return target.split(
" (")[0].strip()
or None
1940 @brief Whether the library the loader resolves lies outside the PETSc the binary was built against.
1941 @param[in] petsc Stamp from `read_binary_petsc_stamp()` or the `--version` line.
1942 @param[in] library Path from `resolved_petsc_library()`.
1943 @return True only when both are known and the library is not under the build's tree.
1945 if not petsc
or not petsc.get(
"dir")
or not library:
1947 if library ==
"not found":
1949 build_root = os.path.realpath(os.path.join(petsc[
"dir"], petsc.get(
"arch")
or ""))
1950 return not os.path.realpath(library).startswith(build_root.rstrip(os.sep) + os.sep)
1955 @brief Read the build identity stamped into an executable's bytes, without running it.
1956 @details src/setup.c stores `PICURV_BUILD:<version> <commit> <dirty>` beside the PETSc
1957 stamp. Running `--version` on a login node starts the dynamic loader against a
1958 cold filesystem, tens of seconds; reading the file costs one read.
1959 @param[in] executable_path Path to `simulator` or `postprocessor`.
1960 @return The identity mapping `read_binary_build_identity()` returns, or None for a binary
1961 built before the stamp existed.
1964 with open(executable_path,
"rb")
as stream:
1965 data = stream.read()
1968 start = data.find(_BINARY_BUILD_STAMP_MARKER)
1971 fields = data[start + len(_BINARY_BUILD_STAMP_MARKER):start + 512].split(b
"\0", 1)[0].decode(
1972 "utf-8", errors=
"replace").split()
1973 if len(fields) != 3
or not re.fullmatch(
r"[0-9a-f]+", fields[1])
or fields[2]
not in (
"true",
"false"):
1975 version, commit, dirty = fields[0], fields[1][:12], fields[2] ==
"true"
1976 release = re.sub(
r"\.dev\d+$",
"", version)
1981 name = os.path.basename(executable_path)
1982 version_line = (f
"{name} {version}+g{commit}{'.dirty' if dirty else ''}\n"
1983 f
"petsc {petsc['version']} {petsc['mode']}"
1984 f
"{' ' + petsc['indices'] if petsc.get('indices_stamped') else ''} {petsc['arch']} {petsc['dir']}")
1987 "path": executable_path,
1988 "version_line": version_line,
1989 "release_version": release,
1990 "git_commit": commit,
1992 "build_id": f
"{version}+g{commit}{'.dirty' if dirty else ''}",
1994 "identity_source":
"stamp",
2000 @brief Read the build identity a native executable was compiled with.
2002 @details The Makefile stamps the release, commit, and dirty state into the
2003 binaries, and they are written into every checkpoint manifest. Reading it
2004 back is what makes the run manifest's provenance a statement about the
2005 binary that ran rather than about whatever source happens to be checked
2006 out when the conductor is invoked.
2007 @param[in] executable_path Path to `simulator` or `postprocessor`.
2008 @return Identity mapping, or an `available: False` mapping when it cannot be read.
2010 if not os.path.isfile(executable_path)
or not os.access(executable_path, os.X_OK):
2011 return {
"available":
False,
"reason":
"not built",
"path": executable_path}
2013 if stamped
is not None:
2016 result = subprocess.run(
2017 [executable_path,
"--version"], text=
True, capture_output=
True, timeout=30,
2019 except (OSError, subprocess.SubprocessError)
as exc:
2020 return {
"available":
False,
"reason": str(exc),
"path": executable_path}
2021 if result.returncode != 0:
2025 detail = (result.stderr
or result.stdout
or "").strip().splitlines()
2026 return {
"available":
False,
2027 "reason": f
"--version exited {result.returncode}"
2028 + (f
": {detail[0]}" if detail
else ""),
2029 "path": executable_path,
2031 lines = (result.stdout
or "").strip().splitlines()
2032 match = _BINARY_VERSION_PATTERN.match(lines[0]
if lines
else "")
2036 return {
"available":
False,
"reason":
"no build identity reported",
2037 "path": executable_path}
2040 "path": executable_path,
2041 "version_line": (result.stdout
or "").strip(),
2042 "release_version": match.group(
"release"),
2043 "git_commit": match.group(
"commit"),
2044 "dirty": bool(match.group(
"dirty")),
2046 f
"{match.group('version')}+g{match.group('commit')}"
2047 f
"{'.dirty' if match.group('dirty') else ''}"
2054 run_dir:
"str | None" =
None) -> dict:
2056 @brief Read the build identity of every native executable a run would launch.
2057 @param[in] source_identity Source identity to compare against; defaults to the
2058 identity this process computed at startup.
2059 @param[in] run_dir Run whose pinned executables are read; the installation's when None,
2060 which also lists the 64-bit-index pair (`simulator64`,
2061 `postprocessor64`) when it is built.
2062 @return Mapping of executable name to its identity, each carrying `matches_source`.
2066 source_commit = str(source_identity.get(
"git_commit")
or "")
2068 if run_dir
is None and _SELECTED_PETSC_INDEX_BYTES
is None:
2069 for name
in RUN_EXECUTABLE_NAMES:
2070 wide = os.path.join(os.path.dirname(paths[name]), name +
"64")
2071 if os.path.isfile(wide):
2072 paths[name +
"64"] = wide
2073 for name, path
in paths.items():
2075 if identity.get(
"available"):
2077 identity[
"matches_source"] = bool(
2079 and source_commit.startswith(identity[
"git_commit"])
2080 and identity[
"dirty"] == bool(source_identity.get(
"dirty"))
2082 identities[name] = identity
2088 @brief The command that rebuilds one native executable.
2089 @details The 64-bit-index pair needs `INDEX_BITS=64` and its own PETSc, named from the
2090 executable's PETSc stamp when it has one; a plain build makes the 32-bit pair.
2091 @param[in] name Executable name, such as `simulator64`.
2092 @param[in] identity Identity mapping from `read_binary_build_identity()`.
2093 @return Shell command text.
2095 if not name.endswith(
"64"):
2096 return "picurv build"
2097 petsc = identity.get(
"petsc")
or {}
2098 command = f
"picurv build INDEX_BITS=64 PETSC_DIR={petsc.get('dir') or '<64-bit PETSc>'}"
2099 return command + (f
" PETSC_ARCH={petsc['arch']}" if petsc.get(
"arch")
else "")
2103 source_identity:
"dict | None" =
None) -> list:
2105 @brief Report every reason the active build identity is not internally coherent.
2107 @details The conductor, the simulator, and the postprocessor are three artifacts
2108 that must agree before a run's provenance means anything. This states the
2109 disagreements rather than printing them, so `version status` can exit on
2110 them while ordinary staging only warns.
2111 @param[in] identities Mapping returned by `runtime_build_identities()`.
2112 @param[in] workspace_requirement Optional `software.picurv` constraint to check.
2113 @param[in] source_identity Source identity the executables must match; defaults to
2114 the identity this process computed at startup.
2115 @return Human-readable problem descriptions; empty when the build is coherent.
2119 for name, identity
in sorted(identities.items()):
2120 if not identity.get(
"available"):
2122 f
"{name}: no build identity available ({identity.get('reason', 'unknown')}); "
2123 f
"rebuild with '{rebuild_command(name, identity)}'."
2125 elif not identity.get(
"matches_source"):
2127 f
"{name}: built from {identity['build_id']}, but the active source is "
2128 f
"{source_identity['build_id']}; rebuild with '{rebuild_command(name, identity)}'."
2130 if workspace_requirement
not in (
None,
""):
2132 from packaging.specifiers
import SpecifierSet
2133 from packaging.version
import Version
2134 requirement_text = str(workspace_requirement).strip()
2135 if not any(token
in requirement_text
for token
in "<>=!~"):
2136 requirement_text =
"==" + requirement_text
2137 satisfied = Version(PICURV_RELEASE_VERSION)
in SpecifierSet(requirement_text)
2140 f
"workspace: software.picurv={workspace_requirement!r} is not a valid "
2141 "version or version range."
2146 f
"workspace: requires PICurv {workspace_requirement!r}, but the active "
2147 f
"release is {PICURV_RELEASE_VERSION}."
2154 @brief Report native executables whose build identity is not the active source.
2155 @details An executable whose identity cannot be read is reported too: the manifest
2156 would otherwise record "unavailable" for a run with no word at staging.
2157 @param[in] identities Mapping returned by `runtime_build_identities()`.
2158 @return Names of executables that disagree with the active source identity.
2160 stale = [name
for name, identity
in sorted(identities.items())
2161 if identity.get(
"available")
and not identity.get(
"matches_source")]
2164 f
"[WARNING] {name} was built from {identities[name]['build_id']}, but the active "
2165 f
"source is {picurv_build()['build_id']}. Checkpoints will record the binary's "
2166 f
"identity, not the source's. Rebuild with '{rebuild_command(name, identities[name])}'.",
2169 for name, identity
in sorted(identities.items()):
2170 if identity.get(
"available"):
2173 f
"[WARNING] The build identity of {name} could not be read "
2174 f
"({identity.get('reason', 'unknown')}); this run's manifest will not record "
2175 f
"which build it used. Path: {identity.get('path')}",
2181ERROR_CODE_CLI_USAGE_INVALID =
"CLI_USAGE_INVALID"
2182ERROR_CODE_CFG_MISSING_SECTION =
"CFG_MISSING_SECTION"
2183ERROR_CODE_CFG_MISSING_KEY =
"CFG_MISSING_KEY"
2184ERROR_CODE_CFG_INVALID_TYPE =
"CFG_INVALID_TYPE"
2185ERROR_CODE_CFG_INVALID_VALUE =
"CFG_INVALID_VALUE"
2186ERROR_CODE_CFG_FILE_NOT_FOUND =
"CFG_FILE_NOT_FOUND"
2187ERROR_CODE_CFG_GRID_PARSE =
"CFG_GRID_PARSE"
2188ERROR_CODE_CFG_INCONSISTENT_COMBO =
"CFG_INCONSISTENT_COMBO"
2189ERROR_CODE_DEPENDENCY_MISSING =
"DEPENDENCY_MISSING"
2191RESTART_RUN_DIR_REQUIRED_MESSAGE = (
2192 "--continue and --restart-from both require an existing run directory. "
2193 "Use --continue --run-dir <run_dir> to resume in place, or "
2194 "--restart-from <run_dir> to create a new run."
2198 ERROR_CODE_CLI_USAGE_INVALID:
"Run 'picurv <command> --help' to see valid argument combinations.",
2199 ERROR_CODE_CFG_MISSING_SECTION:
"Add the missing section using examples/master_template/*.yml as reference.",
2200 ERROR_CODE_CFG_MISSING_KEY:
"Add the missing key in the referenced YAML file.",
2201 ERROR_CODE_CFG_INVALID_TYPE:
"Fix the value type to match the documented schema in docs/pages/14_Config_Contract.md.",
2202 ERROR_CODE_CFG_INVALID_VALUE:
"Adjust the value to a supported range/enum from the config reference pages.",
2203 ERROR_CODE_CFG_FILE_NOT_FOUND:
"Fix the path or create the missing file before running again.",
2204 ERROR_CODE_CFG_GRID_PARSE:
"Validate grid file format and numeric payload (block count, dims, coordinates).",
2205 ERROR_CODE_CFG_INCONSISTENT_COMBO:
"Fix conflicting options/keys so the configuration is internally consistent.",
2206 ERROR_CODE_DEPENDENCY_MISSING:
"Install the named optional dependency for the Python interpreter used by picurv.",
2209_RESTART_HINT = (
"Check --restart-from, --continue and --run-dir against the source run's committed "
2210 "checkpoints and run_control.start_step in case.yml.")
2212_STORAGE_RESTORE_HINT = (
"Run the restore command in the message; 'picurv storage status --run-dir "
2213 "<run>' shows what is local.")
2215_STUDY_DIR_HINT =
"Pass a study directory that 'picurv sweep' created (studies/<study_id>)."
2220 @brief Normalize error fields into a single-line string.
2221 @param[in] value Argument passed to `_sanitize_error_field()`.
2222 @return Value returned by `_sanitize_error_field()`.
2226 text = str(value).strip()
2229 return " ".join(text.splitlines())
2233 message: str =
"", hint: str =
None, stream=
None):
2235 @brief Emit one standardized error line for tooling and users.
2236 @param[in] code Argument passed to `emit_structured_error()`.
2237 @param[in] key Argument passed to `emit_structured_error()`.
2238 @param[in] file_path Argument passed to `emit_structured_error()`.
2239 @param[in] message Argument passed to `emit_structured_error()`.
2240 @param[in] hint Argument passed to `emit_structured_error()`.
2241 @param[in] stream Argument passed to `emit_structured_error()`.
2245 resolved_hint = hint
if hint
is not None else _ERROR_HINTS.get(code,
"-")
2247 f
"ERROR {_sanitize_error_field(code)} | "
2248 f
"key={_sanitize_error_field(key)} | "
2249 f
"file={_sanitize_error_field(file_path)} | "
2250 f
"message={_sanitize_error_field(message)} | "
2251 f
"hint={_sanitize_error_field(resolved_hint)}",
2258 @brief Emit a structured CLI usage error and exit with code 2.
2259 @details Without a @p hint, the default names the command the user ran.
2260 @param[in] message Argument passed to `fail_cli_usage()`.
2261 @param[in] hint Argument passed to `fail_cli_usage()`.
2263 command = next((token
for token
in sys.argv[1:]
if not token.startswith(
"-")),
"")
2265 ERROR_CODE_CLI_USAGE_INVALID,
2269 hint=hint
or _ERROR_HINTS[ERROR_CODE_CLI_USAGE_INVALID].replace(
2270 "<command>", command
if re.fullmatch(
r"[a-z][a-z-]*", command)
else "<command>"),
2277 @brief Separate a validation error into its source-file and message fields when possible.
2278 @param[in] raw_error Validation error text, optionally beginning with a file path and colon.
2279 @return A pair containing the detected file path (or ``-``) and the message text.
2281 text = str(raw_error).strip()
2282 match = re.match(
r"^(?P<file>[^:]+):\s*(?P<msg>.+)$", text)
2285 file_candidate = match.group(
"file").strip()
2286 msg = match.group(
"msg").strip()
2287 known_suffixes = (
".yml",
".yaml",
".cfg",
".picgrid",
".control",
".run",
".txt")
2288 if "/" in file_candidate
or file_candidate.endswith(known_suffixes):
2289 return file_candidate, msg
2295 @brief Best-effort key-path extraction from free-form validation messages.
2296 @param[in] message Argument passed to `_extract_key_path()`.
2297 @return Value returned by `_extract_key_path()`.
2299 dotted = re.search(
r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_\[\]-]+)+)\b", message)
2301 return dotted.group(1)
2303 bracketed = re.search(
r"\b([A-Za-z_][A-Za-z0-9_]*\[[^\]]+\](?:\[[^\]]+\])*)\b", message)
2305 return bracketed.group(1)
2307 quoted = re.findall(
r"'([A-Za-z0-9_.\[\]-]+)'", message)
2308 for token
in quoted:
2309 if "." in token
or "[" in token
or token.isidentifier():
2316 @brief Map existing validation/error messages to the standardized code set.
2317 @param[in] message Argument passed to `_classify_error_code()`.
2318 @return Value returned by `_classify_error_code()`.
2320 msg = message.lower()
2321 if "missing required section" in msg:
2322 return ERROR_CODE_CFG_MISSING_SECTION
2323 if "missing required key" in msg
or "missing key" in msg:
2324 return ERROR_CODE_CFG_MISSING_KEY
2325 if "not found" in msg
or "does not exist" in msg:
2326 return ERROR_CODE_CFG_FILE_NOT_FOUND
2327 if "invalid dimensions line" in msg
or "invalid coordinate row" in msg
or "grid file" in msg:
2328 return ERROR_CODE_CFG_GRID_PARSE
2330 "must both be periodic" in msg
2331 or "inconsistent periodicity" in msg
2332 or "mismatch" in msg
2333 or "requires --" in msg
2334 or "must be 1 (auto) or exactly" in msg
2336 return ERROR_CODE_CFG_INCONSISTENT_COMBO
2338 "must be a mapping" in msg
2339 or "must be a list" in msg
2340 or "must be a string" in msg
2341 or "must be a boolean" in msg
2342 or "must be either" in msg
2344 return ERROR_CODE_CFG_INVALID_TYPE
2345 if "unsupported key" in msg
or "unsupported top-level section" in msg:
2346 return ERROR_CODE_CFG_INVALID_VALUE
2347 return ERROR_CODE_CFG_INVALID_VALUE
2355 @brief Safely reads a YAML file and returns its content.
2356 @param[in] filepath Path to the YAML file.
2357 @return A dictionary containing the parsed YAML content.
2358 @throws SystemExit if the file is not found or cannot be parsed.
2360 if not os.path.exists(filepath):
2362 ERROR_CODE_CFG_FILE_NOT_FOUND,
2365 message=
"Configuration file not found.",
2369 with open(filepath,
'r')
as f:
2370 return yaml.safe_load(f)
2371 except yaml.YAMLError
as e:
2373 ERROR_CODE_CFG_INVALID_VALUE,
2376 message=f
"YAML parse error: {e}",
2377 hint=
"Fix YAML syntax/indentation and retry validation.",
2383 @brief Write YAML with stable ordering for generated study artifacts.
2384 @param[in] filepath Argument passed to `write_yaml_file()`.
2385 @param[in] data Argument passed to `write_yaml_file()`.
2387 path = os.path.abspath(filepath)
2388 os.makedirs(os.path.dirname(path), exist_ok=
True)
2389 temporary = f
"{path}.tmp.{os.getpid()}"
2390 with open(temporary,
"w")
as f:
2391 yaml.safe_dump(data, f, sort_keys=
False)
2393 os.fsync(f.fileno())
2394 os.replace(temporary, path)
2398 @brief Write JSON metadata/manifests with a stable, readable format.
2399 @param[in] filepath Argument passed to `write_json_file()`.
2400 @param[in] payload Argument passed to `write_json_file()`.
2402 path = os.path.abspath(filepath)
2403 os.makedirs(os.path.dirname(path), exist_ok=
True)
2404 temporary = f
"{path}.tmp.{os.getpid()}"
2405 with open(temporary,
"w")
as f:
2406 json.dump(payload, f, indent=2, sort_keys=
True)
2409 os.fsync(f.fileno())
2410 os.replace(temporary, path)
2415 @brief Write a default runtime execution config, copying a source template when available.
2416 @param[in] filepath Argument passed to `write_runtime_execution_file()`.
2417 @param[in] template_source_path Argument passed to `write_runtime_execution_file()`.
2418 @return Value returned by `write_runtime_execution_file()`.
2420 os.makedirs(os.path.dirname(filepath), exist_ok=
True)
2422 if template_source_path
and os.path.isfile(template_source_path):
2423 shutil.copy2(template_source_path, filepath)
2426 with open(filepath,
"w", encoding=
"utf-8")
as f:
2427 f.write(DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE)
2433 @brief Return True when a launcher arg token contains embedded whitespace and should be split.
2434 @param[in] token Argument passed to `_launcher_arg_contains_whitespace()`.
2435 @return Value returned by `_launcher_arg_contains_whitespace()`.
2437 return isinstance(token, str)
and any(ch.isspace()
for ch
in token.strip())
2442 @brief Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
2443 @param[in] source_project_root Argument passed to `resolve_runtime_execution_seed_source()`.
2444 @return Value returned by `resolve_runtime_execution_seed_source()`.
2446 source_root_abs = os.path.abspath(source_project_root)
2447 repo_local_runtime = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2448 if os.path.isfile(repo_local_runtime):
2449 return repo_local_runtime
2451 tracked_example = os.path.join(
2455 RUNTIME_EXECUTION_EXAMPLE_FILENAME,
2457 if os.path.isfile(tracked_example):
2458 return tracked_example
2464 @brief Create case-local runtime execution config if missing, seeded from repo-local config when available.
2465 @param[in] case_dir Argument passed to `ensure_case_runtime_execution_config()`.
2466 @param[in] source_project_root Argument passed to `ensure_case_runtime_execution_config()`.
2467 @param[in] overwrite Argument passed to `ensure_case_runtime_execution_config()`.
2468 @return Value returned by `ensure_case_runtime_execution_config()`.
2470 case_dir_abs = os.path.abspath(case_dir)
2471 dest_path = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2472 if os.path.exists(dest_path)
and not overwrite:
2476 "seed_source":
None,
2484 "seed_source": seed_source,
2490 @brief Return True when a directory looks like the PICurv source repository root.
2491 @param[in] candidate Argument passed to `is_project_root()`.
2492 @return Value returned by `is_project_root()`.
2496 candidate_abs = os.path.abspath(candidate)
2498 os.path.isfile(os.path.join(candidate_abs,
"Makefile"))
2499 and os.path.isdir(os.path.join(candidate_abs,
"src"))
2500 and os.path.isdir(os.path.join(candidate_abs,
"include"))
2501 and os.path.isdir(os.path.join(candidate_abs,
"picurv_cli"))
2507 @brief Yield a path and all of its parents up to filesystem root.
2508 @param[in] start_path Argument passed to `_iter_parent_dirs()`.
2510 current = os.path.abspath(start_path)
2511 if os.path.isfile(current):
2512 current = os.path.dirname(current)
2515 parent = os.path.dirname(current)
2516 if parent == current:
2523 @brief Search upward from an anchor and return the first matching project root.
2524 @param[in] start_path Argument passed to `find_project_root_upwards()`.
2525 @return Value returned by `find_project_root_upwards()`.
2537 @brief Best-effort source repo discovery from runtime anchors.
2538 @param[in] extra_anchors Argument passed to `discover_local_project_root()`.
2539 @return Value returned by `discover_local_project_root()`.
2541 anchors =
list(extra_anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR, SCRIPT_PATH, PROJECT_ROOT]
2543 for anchor
in anchors:
2546 anchor_abs = os.path.abspath(anchor)
2547 if anchor_abs
in seen:
2549 seen.add(anchor_abs)
2558 @brief Find the nearest case-origin metadata file from known runtime anchors.
2559 @details An explicit case directory is the only anchor searched, so a wrong one cannot
2560 fall back to the case in the working directory.
2561 @param[in] case_dir_hint Argument passed to `find_case_origin_metadata_file()`.
2562 @return Value returned by `find_case_origin_metadata_file()`.
2563 @throws ValueError when @p case_dir_hint is given but is not a directory.
2565 if case_dir_hint
and not os.path.isdir(case_dir_hint):
2566 raise ValueError(f
"Case directory does not exist: {os.path.abspath(case_dir_hint)}")
2568 for candidate
in (case_dir_hint,)
if case_dir_hint
else (os.getcwd(), INVOKED_SCRIPT_DIR):
2571 abs_candidate = os.path.abspath(candidate)
2572 if abs_candidate
not in search_roots:
2573 search_roots.append(abs_candidate)
2575 for root
in search_roots:
2577 metadata_path = os.path.join(directory, CASE_ORIGIN_METADATA_FILENAME)
2578 if os.path.isfile(metadata_path):
2579 return metadata_path
2585 @brief Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
2586 @param[in] case_dir_hint Argument passed to `load_case_origin_metadata()`.
2587 @return Value returned by `load_case_origin_metadata()`.
2590 if not metadata_path:
2591 return None,
None,
None
2593 with open(metadata_path,
"r", encoding=
"utf-8")
as f:
2594 payload = json.load(f)
2595 if not isinstance(payload, dict):
2596 raise ValueError(
"Case origin metadata must be a JSON object.")
2597 except Exception
as exc:
2598 raise ValueError(f
"Failed to read case origin metadata at {metadata_path}: {exc}")
from exc
2599 return os.path.dirname(metadata_path), metadata_path, payload
2604 @brief Find the nearest optional execution config from runtime/case anchors.
2605 @param[in] anchors Argument passed to `find_runtime_execution_config_file()`.
2606 @return Value returned by `find_runtime_execution_config_file()`.
2610 for candidate
in list(anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR]:
2613 current = os.path.abspath(candidate)
2614 if os.path.isfile(current):
2615 current = os.path.dirname(current)
2619 search_roots.append(current)
2622 for root
in search_roots:
2624 if directory
in seen_dirs:
2626 seen_dirs.add(directory)
2627 for filename
in RUNTIME_EXECUTION_CONFIG_FILENAMES:
2628 config_path = os.path.join(directory, filename)
2629 if os.path.isfile(config_path):
2636 @brief Validate one execution override section while preserving missing-vs-empty semantics.
2637 @param[in] payload Argument passed to `_normalize_execution_override_section()`.
2638 @param[in] section_name Argument passed to `_normalize_execution_override_section()`.
2639 @param[in] config_path Argument passed to `_normalize_execution_override_section()`.
2640 @param[in] config_label Argument passed to `_normalize_execution_override_section()`.
2641 @return Value returned by `_normalize_execution_override_section()`.
2643 section = payload.get(section_name)
2645 return {
"launcher":
None,
"launcher_args":
None}
2646 if not isinstance(section, dict):
2647 raise ValueError(f
"{config_label} at {config_path}: {section_name} must be a mapping.")
2649 launcher = section.get(
"launcher")
2650 if launcher
is not None and not isinstance(launcher, str):
2651 raise ValueError(f
"{config_label} at {config_path}: {section_name}.launcher must be a string.")
2653 launcher_args =
None
2654 if "launcher_args" in section:
2655 launcher_args = section.get(
"launcher_args", [])
2656 if launcher_args
is None:
2658 if not isinstance(launcher_args, list):
2659 raise ValueError(f
"{config_label} at {config_path}: {section_name}.launcher_args must be a list.")
2660 for i, token
in enumerate(launcher_args):
2661 if not isinstance(token, (str, int, float)):
2663 f
"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2664 "must be a scalar CLI token."
2668 f
"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2669 "must be a single CLI token; split whitespace-separated arguments into separate list items."
2671 launcher_args = [str(x)
for x
in launcher_args]
2674 "launcher": launcher,
2675 "launcher_args": launcher_args,
2681 @brief Load optional shared execution launcher config from the nearest runtime config file.
2682 @param[in] config_search_anchor Argument passed to `load_runtime_execution_config()`.
2683 @param[in] extra_search_anchors Argument passed to `load_runtime_execution_config()`.
2684 @return Value returned by `load_runtime_execution_config()`.
2687 if config_search_anchor
is not None:
2688 anchors.append(config_search_anchor)
2689 if extra_search_anchors:
2690 anchors.extend(extra_search_anchors)
2697 with open(config_path,
"r", encoding=
"utf-8")
as f:
2698 payload = yaml.safe_load(f)
or {}
2699 except yaml.YAMLError
as exc:
2700 raise ValueError(f
"{os.path.basename(config_path)} YAML parse error at {config_path}: {exc}")
from exc
2702 if not isinstance(payload, dict):
2703 raise ValueError(f
"{os.path.basename(config_path)} at {config_path} must be a YAML mapping.")
2705 return config_path, {
2708 "default_execution",
2710 os.path.basename(config_path),
2716 os.path.basename(config_path),
2720 "cluster_execution",
2722 os.path.basename(config_path),
2729 @brief Merge execution overrides, letting explicit override values win key-by-key.
2730 @param[in] base Argument passed to `merge_execution_overrides()`.
2731 @param[in] override Argument passed to `merge_execution_overrides()`.
2732 @return Value returned by `merge_execution_overrides()`.
2735 override = override
or {}
2737 launcher = override.get(
"launcher")
2738 if launcher
is None:
2739 launcher = base.get(
"launcher")
2741 launcher_args = override.get(
"launcher_args")
2742 if launcher_args
is None:
2743 launcher_args = base.get(
"launcher_args")
2746 "launcher": launcher,
2747 "launcher_args":
None if launcher_args
is None else [str(x)
for x
in launcher_args],
2753 @brief Resolve default plus context-specific execution overrides.
2754 @param[in] runtime_execution_cfg Argument passed to `resolve_runtime_execution_context()`.
2755 @param[in] context Argument passed to `resolve_runtime_execution_context()`.
2756 @return Value returned by `resolve_runtime_execution_context()`.
2758 if context
not in LAUNCH_MODES:
2759 raise ValueError(f
"Unsupported execution context '{context}'.")
2761 runtime_execution_cfg.get(
"default_execution"),
2762 runtime_execution_cfg.get(f
"{context}_execution"),
2768 @brief Best-effort git commit lookup for run/study manifests and case metadata.
2769 @param[in] repo_root Argument passed to `get_git_commit()`.
2770 @return Value returned by `get_git_commit()`.
2772 cwd = repo_root
or PROJECT_ROOT
2774 result = subprocess.run(
2775 [
"git",
"rev-parse",
"HEAD"],
2778 capture_output=
True,
2781 if result.returncode == 0:
2782 return result.stdout.strip()
2789 existing: dict =
None, template_managed_files=
None):
2791 @brief Create or refresh case-origin metadata for repo-aware case maintenance commands.
2792 @param[in] case_dir Argument passed to `write_case_origin_metadata()`.
2793 @param[in] source_project_root Argument passed to `write_case_origin_metadata()`.
2794 @param[in] template_name Argument passed to `write_case_origin_metadata()`.
2795 @param[in] existing Argument passed to `write_case_origin_metadata()`.
2796 @param[in] template_managed_files Argument passed to `write_case_origin_metadata()`.
2797 @return Value returned by `write_case_origin_metadata()`.
2799 payload = dict(existing
or {})
2800 if "initialized_at" not in payload:
2801 payload[
"initialized_at"] = datetime.now().isoformat()
2802 payload[
"source_repo_root"] = os.path.abspath(source_project_root)
2804 payload[
"template_name"] = template_name
2805 if template_managed_files
is not None:
2806 payload[
"template_managed_files"] = sorted(set(str(p)
for p
in template_managed_files))
2807 payload[
"last_known_source_git_commit"] =
get_git_commit(source_project_root)
2808 metadata_path = os.path.join(os.path.abspath(case_dir), CASE_ORIGIN_METADATA_FILENAME)
2810 return metadata_path, payload
2814_MAKE_ASSIGNMENT_PATTERN = re.compile(
r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
2819 @brief Return True when make args contain an explicit target rather than only options/assignments.
2820 @param[in] make_args Argument passed to `make_args_include_explicit_goal()`.
2821 @return Value returned by `make_args_include_explicit_goal()`.
2826 options_with_value = {
2827 "-C",
"-f",
"-I",
"-j",
"-l",
"-o",
"-W",
2828 "--directory",
"--file",
"--makefile",
"--include-dir",
"--jobs",
2829 "--load-average",
"--max-load",
"--old-file",
"--assume-old",
2830 "--what-if",
"--new-file",
"--assume-new",
2834 for token
in make_args:
2838 if token
in options_with_value:
2841 if token.startswith(
"-"):
2843 if _MAKE_ASSIGNMENT_PATTERN.match(token):
2851 @brief Resolve case directory, source repo root, and optional template metadata.
2852 @param[in] case_dir_hint Argument passed to `resolve_case_origin_context()`.
2853 @param[in] source_root_override Argument passed to `resolve_case_origin_context()`.
2854 @param[in] template_name_override Argument passed to `resolve_case_origin_context()`.
2855 @return Value returned by `resolve_case_origin_context()`.
2859 if metadata_case_dir:
2860 case_dir = metadata_case_dir
2862 case_dir = os.path.abspath(case_dir_hint
or os.getcwd())
2864 source_project_root = source_root_override
2865 if source_project_root:
2866 source_project_root = os.path.abspath(source_project_root)
2867 elif isinstance(metadata, dict)
and isinstance(metadata.get(
"source_repo_root"), str):
2868 source_project_root = os.path.abspath(metadata[
"source_repo_root"])
2872 template_name = template_name_override
2873 if not template_name
and isinstance(metadata, dict):
2874 template_name = metadata.get(
"template_name")
2877 "case_dir": case_dir,
2878 "metadata_path": metadata_path,
2879 "metadata": metadata
or {},
2880 "source_project_root": source_project_root,
2881 "template_name": template_name,
2887 @brief Validate that a source repo root was resolved and is structurally valid.
2888 @param[in] candidate Argument passed to `require_project_root()`.
2889 @param[in] purpose Argument passed to `require_project_root()`.
2890 @return Value returned by `require_project_root()`.
2894 f
"Could not determine the PICurv source repository for {purpose}. "
2895 "Run this command from an initialized case directory or pass --source-root."
2897 candidate_abs = os.path.abspath(candidate)
2900 f
"Resolved source repository for {purpose} is not a valid PICurv root: {candidate_abs}"
2902 return candidate_abs
2907 @brief Validate that a target case directory exists and is not the source repo root.
2908 @param[in] case_dir Argument passed to `require_existing_case_dir()`.
2909 @param[in] purpose Argument passed to `require_existing_case_dir()`.
2910 @param[in] source_project_root Argument passed to `require_existing_case_dir()`.
2911 @return Value returned by `require_existing_case_dir()`.
2914 raise ValueError(f
"Could not determine the case directory for {purpose}. Pass --case-dir.")
2915 case_dir_abs = os.path.abspath(case_dir)
2916 if not os.path.isdir(case_dir_abs):
2917 raise ValueError(f
"Case directory for {purpose} does not exist: {case_dir_abs}")
2918 if source_project_root
and os.path.abspath(source_project_root) == case_dir_abs:
2920 f
"Refusing to run {purpose} against the source repository root itself: {case_dir_abs}"
2927 @brief Resolve an example template directory inside the source repository.
2928 @param[in] source_project_root Argument passed to `resolve_template_directory()`.
2929 @param[in] template_name Argument passed to `resolve_template_directory()`.
2930 @return Value returned by `resolve_template_directory()`.
2932 if not template_name:
2934 "Template name is required for config sync. Re-run with --template-name or from a case initialized by current picurv."
2936 template_dir = os.path.join(source_project_root,
"examples", template_name)
2937 if not os.path.isdir(template_dir):
2938 raise ValueError(f
"Case template '{template_name}' not found at '{template_dir}'")
2944 @brief List all files in a template directory as case-relative paths.
2945 @param[in] template_dir Argument passed to `list_template_relative_files()`.
2946 @param[in] excluded_rel_paths Argument passed to `list_template_relative_files()`.
2947 @return Value returned by `list_template_relative_files()`.
2949 template_dir_abs = os.path.abspath(template_dir)
2950 if not os.path.isdir(template_dir_abs):
2951 raise ValueError(f
"Template directory not found: {template_dir_abs}")
2952 excluded = set(excluded_rel_paths
or [])
2954 for root, _, files
in os.walk(template_dir_abs):
2955 rel_root = os.path.relpath(root, template_dir_abs)
2956 for filename
in sorted(files):
2957 rel_path = filename
if rel_root ==
"." else os.path.join(rel_root, filename)
2958 if rel_path
in excluded:
2960 relative_paths.append(rel_path)
2961 return relative_paths
2966 @brief Render an example template into the canonical workspace layout at a new path.
2967 @details This is the one transformation `init` applies to a template: copy it, drop the
2968 execution example, then relocate and rewrite its files through
2969 organize_initialized_workspace(). `sync-config` and `status-source` render the
2970 current template the same way, so all three agree on where each file lives and
2972 @param[in] template_dir Example template directory to render.
2973 @param[in] template_name Example template identity.
2974 @param[in] destination Path to render into; it must not exist.
2975 @return The layout mapping returned by organize_initialized_workspace().
2977 shutil.copytree(template_dir, destination)
2978 copied_runtime_example = os.path.join(destination, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
2979 if os.path.isfile(copied_runtime_example):
2980 os.remove(copied_runtime_example)
2986 @brief List the template-managed files of a rendered workspace, workspace-relative.
2987 @details The workspace identity file is excluded: it records when and where the
2988 workspace was created, which is the workspace's own state, not the template's.
2989 @param[in] rendered_root Root produced by render_template_workspace().
2990 @return Sorted workspace-relative paths.
2994 if rel != WORKSPACE_CONFIG_FILENAME
3000 @brief List binary artifacts currently available in the source repo bin directory.
3001 @param[in] source_project_root Argument passed to `list_source_binaries()`.
3002 @return Value returned by `list_source_binaries()`.
3004 source_bin_dir = os.path.join(os.path.abspath(source_project_root),
"bin")
3005 if not os.path.isdir(source_bin_dir):
3006 raise ValueError(f
"Source bin directory not found: {source_bin_dir}. Run 'picurv build' first.")
3008 f
for f
in os.listdir(source_bin_dir)
3009 if os.path.isfile(os.path.join(source_bin_dir, f))
and f !=
"picurv"
3012 raise ValueError(f
"Source bin directory contains no files: {source_bin_dir}")
3013 return source_bin_dir, binaries
3018 @brief Copy current source-repo binaries into a case directory for version-pinning.
3019 @param[in] case_dir Argument passed to `sync_case_binaries()`.
3020 @param[in] source_project_root Argument passed to `sync_case_binaries()`.
3021 @return Value returned by `sync_case_binaries()`.
3023 case_dir_abs = os.path.abspath(case_dir)
3024 os.makedirs(case_dir_abs, exist_ok=
True)
3027 for binary_name
in binaries:
3028 source_path = os.path.join(source_bin_dir, binary_name)
3029 dest_path = os.path.join(case_dir_abs, binary_name)
3030 shutil.copy2(source_path, dest_path)
3031 copied.append(dest_path)
3036 prune: bool =
False, managed_rel_paths=
None, template_name: str =
None):
3038 @brief Sync template files into a case directory, preserving modified files unless overwrite is requested.
3039 @param[in] case_dir Argument passed to `sync_case_template_files()`.
3040 @param[in] template_dir Argument passed to `sync_case_template_files()`.
3041 @param[in] overwrite Argument passed to `sync_case_template_files()`.
3042 @param[in] prune Argument passed to `sync_case_template_files()`.
3043 @param[in] managed_rel_paths Argument passed to `sync_case_template_files()`.
3044 @param[in] template_name Template identity; defaults to the template directory name.
3045 @return Value returned by `sync_case_template_files()`.
3046 @details Files are compared with a fresh rendering of the template in the workspace
3047 layout `init` creates, so a case file is matched with the template file it
3048 was made from, under its workspace name and rewritten contents.
3050 case_dir_abs = os.path.abspath(case_dir)
3051 template_dir_abs = os.path.abspath(template_dir)
3052 if not os.path.isdir(template_dir_abs):
3053 raise ValueError(f
"Template directory not found: {template_dir_abs}")
3054 template_name = template_name
or os.path.basename(template_dir_abs)
3059 "skipped_modified": [],
3062 "prune_requested_without_tracking":
False,
3064 with tempfile.TemporaryDirectory(prefix=
"picurv-template-")
as scratch:
3065 rendered_root = os.path.join(scratch, os.path.basename(case_dir_abs)
or "workspace")
3068 for rel_path
in current_template_files:
3069 src_path = os.path.join(rendered_root, rel_path)
3070 dest_path = os.path.join(case_dir_abs, rel_path)
3071 os.makedirs(os.path.dirname(dest_path), exist_ok=
True)
3073 if not os.path.exists(dest_path):
3074 shutil.copy2(src_path, dest_path)
3075 summary[
"copied"].append(dest_path)
3078 if filecmp.cmp(src_path, dest_path, shallow=
False):
3079 summary[
"unchanged"].append(dest_path)
3083 shutil.copy2(src_path, dest_path)
3084 summary[
"overwritten"].append(dest_path)
3086 summary[
"skipped_modified"].append(dest_path)
3087 current_template_set = set(current_template_files)
3089 managed_set = set(managed_rel_paths
or [])
3092 summary[
"prune_requested_without_tracking"] =
True
3093 for rel_path
in sorted(managed_set - current_template_set):
3094 dest_path = os.path.join(case_dir_abs, rel_path)
3095 if os.path.isfile(dest_path):
3096 os.remove(dest_path)
3097 summary[
"pruned"].append(dest_path)
3099 summary[
"template_managed_files"] = current_template_files
3105 @brief Compute source/case drift across commits, binaries, and template-managed files.
3106 @param[in] case_dir Argument passed to `compute_case_source_status()`.
3107 @param[in] source_project_root Argument passed to `compute_case_source_status()`.
3108 @param[in] template_name Argument passed to `compute_case_source_status()`.
3109 @param[in] metadata Argument passed to `compute_case_source_status()`.
3110 @return Value returned by `compute_case_source_status()`.
3112 case_dir_abs = os.path.abspath(case_dir)
3113 source_root_abs = os.path.abspath(source_project_root)
3114 metadata = metadata
or {}
3116 "case_dir": case_dir_abs,
3117 "source_repo_root": source_root_abs,
3118 "metadata_present": bool(metadata),
3119 "template_name": template_name,
3120 "last_known_source_git_commit": metadata.get(
"last_known_source_git_commit"),
3123 status[
"source_commit_changed"] = (
3124 bool(status[
"last_known_source_git_commit"])
3125 and bool(status[
"current_source_git_commit"])
3126 and status[
"last_known_source_git_commit"] != status[
"current_source_git_commit"]
3130 "source_bin_present":
False,
3131 "source_bin_missing": [],
3132 "case_bin_missing": [],
3133 "case_bin_different": [],
3134 "case_bin_current": [],
3138 binary_status[
"source_bin_present"] =
True
3139 for binary_name
in binaries:
3140 source_path = os.path.join(source_bin_dir, binary_name)
3141 case_path = os.path.join(case_dir_abs, binary_name)
3142 if not os.path.isfile(case_path):
3143 binary_status[
"case_bin_missing"].append(binary_name)
3144 elif filecmp.cmp(source_path, case_path, shallow=
False):
3145 binary_status[
"case_bin_current"].append(binary_name)
3147 binary_status[
"case_bin_different"].append(binary_name)
3148 except ValueError
as exc:
3149 binary_status[
"source_bin_missing"].append(str(exc))
3150 status[
"binaries"] = binary_status
3153 "template_available":
False,
3154 "template_files": [],
3155 "case_missing_files": [],
3156 "case_modified_files": [],
3157 "case_current_files": [],
3158 "template_removed_since_last_sync": [],
3159 "tracking_available": isinstance(metadata.get(
"template_managed_files"), list),
3164 with tempfile.TemporaryDirectory(prefix=
"picurv-template-")
as scratch:
3165 rendered_root = os.path.join(scratch, os.path.basename(case_dir_abs)
or "workspace")
3168 config_status[
"template_available"] =
True
3169 config_status[
"template_files"] = template_files
3170 for rel_path
in template_files:
3171 src_path = os.path.join(rendered_root, rel_path)
3172 case_path = os.path.join(case_dir_abs, rel_path)
3173 if not os.path.isfile(case_path):
3174 config_status[
"case_missing_files"].append(rel_path)
3175 elif filecmp.cmp(src_path, case_path, shallow=
False):
3176 config_status[
"case_current_files"].append(rel_path)
3178 config_status[
"case_modified_files"].append(rel_path)
3179 managed_files = metadata.get(
"template_managed_files")
3180 if isinstance(managed_files, list):
3181 config_status[
"template_removed_since_last_sync"] = sorted(set(managed_files) - set(template_files))
3184 status[
"config"] = config_status
3186 case_runtime_cfg = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
3187 repo_runtime_seed = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
3189 "case_config_present": os.path.isfile(case_runtime_cfg),
3190 "repo_seed_present": os.path.isfile(repo_runtime_seed),
3191 "case_matches_repo_seed":
False,
3193 if runtime_status[
"case_config_present"]
and runtime_status[
"repo_seed_present"]:
3194 runtime_status[
"case_matches_repo_seed"] = filecmp.cmp(
3199 status[
"runtime_execution"] = runtime_status
3205 @brief Render human-readable source/case drift details.
3206 @param[in] status Argument passed to `print_case_source_status()`.
3208 print(f
"[INFO] Case directory : {status['case_dir']}")
3209 print(f
"[INFO] Source repo : {status['source_repo_root']}")
3210 print(f
"[INFO] Template : {status.get('template_name') or '(unknown)'}")
3211 if status.get(
"last_known_source_git_commit"):
3212 print(f
"[INFO] Last synced commit : {status['last_known_source_git_commit']}")
3213 if status.get(
"current_source_git_commit"):
3214 print(f
"[INFO] Current src commit : {status['current_source_git_commit']}")
3215 print(f
"[INFO] Source changed : {'yes' if status.get('source_commit_changed') else 'no'}")
3217 binaries = status[
"binaries"]
3218 if binaries[
"source_bin_present"]
and not (binaries[
"case_bin_current"]
or binaries[
"case_bin_different"]):
3219 print(
"[INFO] Binaries : not pinned (the installation's executables are used)")
3220 elif binaries[
"source_bin_present"]:
3222 f
"[INFO] Binaries : current={len(binaries['case_bin_current'])} "
3223 f
"changed={len(binaries['case_bin_different'])} missing={len(binaries['case_bin_missing'])}"
3226 print(
"[INFO] Binaries : source bin/ unavailable")
3228 config = status[
"config"]
3229 if config[
"template_available"]:
3231 f
"[INFO] Template files : current={len(config['case_current_files'])} "
3232 f
"modified={len(config['case_modified_files'])} missing={len(config['case_missing_files'])}"
3234 if config[
"tracking_available"]:
3235 print(f
"[INFO] Prune candidates : {len(config['template_removed_since_last_sync'])}")
3237 print(
"[INFO] Prune candidates : tracking unavailable")
3238 elif status.get(
"template_name"):
3239 print(
"[INFO] Template files : template unavailable in source repo")
3241 runtime_cfg = status.get(
"runtime_execution", {})
3243 f
"[INFO] Runtime config : case={'yes' if runtime_cfg.get('case_config_present') else 'no'} "
3244 f
"repo-seed={'yes' if runtime_cfg.get('repo_seed_present') else 'no'} "
3245 f
"matches-repo-seed={'yes' if runtime_cfg.get('case_matches_repo_seed') else 'no'}"
3251 @brief Report source/case drift for an initialized case directory.
3252 @param[in] args Command-line style argument list supplied to the function.
3256 case_dir_hint=getattr(args,
"case_dir",
None),
3257 source_root_override=getattr(args,
"source_root",
None),
3258 template_name_override=getattr(args,
"template_name",
None),
3264 source_project_root,
3265 template_name=context.get(
"template_name"),
3266 metadata=context.get(
"metadata"),
3268 except ValueError
as exc:
3269 print(f
"[FATAL] {exc}", file=sys.stderr)
3272 if getattr(args,
"output_format",
"text") ==
"json":
3273 print(json.dumps(status, indent=2, sort_keys=
True))
3279 @brief Resolve a potentially relative path against a source YAML file path.
3280 @param[in] anchor_file Argument passed to `resolve_path()`.
3281 @param[in] candidate Argument passed to `resolve_path()`.
3282 @return Value returned by `resolve_path()`.
3284 if candidate
is None:
3289POST_RUN_CONTROL_ALIASES = {
3290 "start_step": (
"start_step",
"startTime"),
3291 "end_step": (
"end_step",
"endTime"),
3292 "step_interval": (
"step_interval",
"timeStep"),
3296GRID_GENERATOR_HYPHEN_KEY_HINTS = {
3297 "config-file":
"config_file",
3298 "grid-type":
"grid_type",
3299 "cli-args":
"cli_args",
3305RETIRED_GENERATOR_DESTINATION_KEYS = (
"output_file",
"stats_file",
"vts_file",
3306 "output-file",
"stats-file",
"vts-file")
3311 @brief Return the first defined value from a mapping across alias keys.
3312 @param[in] mapping Argument passed to `_mapping_value_with_aliases()`.
3313 @param[in] default Argument passed to `_mapping_value_with_aliases()`.
3314 @param[in] keys Argument passed to `_mapping_value_with_aliases()`.
3315 @return Value returned by `_mapping_value_with_aliases()`.
3317 if not isinstance(mapping, dict):
3321 return mapping.get(key)
3327 @brief Resolve post run_control values with backwards-compatible legacy aliases.
3328 @param[in] post_cfg Argument passed to `get_post_run_control_value()`.
3329 @param[in] canonical_key Argument passed to `get_post_run_control_value()`.
3330 @param[in] default Argument passed to `get_post_run_control_value()`.
3331 @return Value returned by `get_post_run_control_value()`.
3333 aliases = POST_RUN_CONTROL_ALIASES.get(canonical_key, (canonical_key,))
3334 rc = post_cfg.get(
"run_control", {})
3340 @brief Warn when grid.generator uses unsupported hyphenated wrapper keys.
3341 @param[in] generator grid.generator mapping from case.yml.
3342 @param[in] case_path Case file path for diagnostics.
3343 @param[in,out] warnings Warning list to append to.
3345 if not isinstance(generator, dict):
3347 for bad_key, expected_key
in GRID_GENERATOR_HYPHEN_KEY_HINTS.items():
3348 if bad_key
in generator
and bad_key != expected_key:
3350 f
"{case_path}: grid.generator.{bad_key} is ignored; use grid.generator.{expected_key}."
3356RETIRED_INITIAL_CONDITION_DESTINATION_KEYS = (
"output_file",
"summary_json",
"spectrum_csv")
3361 @brief Explain why a generator may not choose its own output destination.
3362 @param[in] label Dotted configuration path of the mapping carrying the key.
3363 @param[in] key The refused destination key.
3364 @return The refusal message, shared by every generator that once took one.
3367 f
"'{label}.{key}' is no longer accepted. PICurv chooses where generated artifacts "
3368 "go; the published asset carries the payload, its preview, and its validation record."
3374 @brief Reject generator settings that try to choose their own output destination.
3375 @param[in] generator Generator mapping from the case configuration.
3376 @param[in] case_path Case file path for diagnostics.
3377 @param[in] label Dotted configuration path being checked, for the message.
3378 @return List of error strings.
3380 if not isinstance(generator, dict):
3383 f
" {case_path}: {retired_destination_message(label, key)}"
3384 for key
in RETIRED_GENERATOR_DESTINATION_KEYS
if key
in generator
3390 @brief Return source_data as a mapping when valid, else an empty mapping.
3391 @param[in] post_cfg Argument passed to `get_post_source_data()`.
3392 @return Value returned by `get_post_source_data()`.
3394 source_cfg = post_cfg.get(
"source_data", {})
3395 if isinstance(source_cfg, dict):
3402 @brief Resolve the source directory template from source_data with a safe default.
3403 @param[in] post_cfg Argument passed to `get_post_source_directory_template()`.
3404 @param[in] default Argument passed to `get_post_source_directory_template()`.
3405 @return Value returned by `get_post_source_directory_template()`.
3412 @brief Return post input_extensions, preferring io.* and tolerating legacy source_data.* placement.
3413 @param[in] post_cfg Argument passed to `get_post_input_extensions()`.
3414 @return Value returned by `get_post_input_extensions()`.
3416 io_cfg = post_cfg.get(
"io", {})
3417 io_ext = io_cfg.get(
"input_extensions")
if isinstance(io_cfg, dict)
else None
3418 if isinstance(io_ext, dict):
3422 if isinstance(source_ext, dict):
3430 @brief Return normalized statistics pipeline tokens that will be written into post.run.
3431 @param[in] post_cfg Argument passed to `get_post_statistics_task_tokens()`.
3432 @return Value returned by `get_post_statistics_task_tokens()`.
3434 stats_cfg = post_cfg.get(
"statistics_pipeline")
3436 if isinstance(stats_cfg, list):
3437 stats_entries = stats_cfg
3438 elif isinstance(stats_cfg, dict):
3439 stats_entries = stats_cfg.get(
"tasks", [])
3442 for entry
in stats_entries:
3443 if isinstance(entry, str):
3445 elif isinstance(entry, dict):
3446 task_name = entry.get(
"task")
3458 @brief Resolve the solver output root from monitor.yml, preserving the default layout.
3459 @param[in] monitor_cfg Argument passed to `get_monitor_output_directory()`.
3460 @param[in] default Argument passed to `get_monitor_output_directory()`.
3461 @return Value returned by `get_monitor_output_directory()`.
3463 del monitor_cfg, default
3464 return CANONICAL_RUN_PATHS[
"output"]
3469 @brief Resolve the statistics CSV prefix, preserving legacy top-level override support.
3470 @param[in] post_cfg Argument passed to `get_post_statistics_output_prefix()`.
3471 @param[in] default Argument passed to `get_post_statistics_output_prefix()`.
3472 @return Value returned by `get_post_statistics_output_prefix()`.
3474 stats_cfg = post_cfg.get(
"statistics_pipeline")
3475 if isinstance(stats_cfg, dict):
3476 prefix = stats_cfg.get(
"output_prefix")
3477 if isinstance(prefix, str)
and prefix.strip():
3478 return prefix.strip()
3480 legacy_prefix = post_cfg.get(
"statistics_output_prefix")
3481 if isinstance(legacy_prefix, str)
and legacy_prefix.strip():
3482 return legacy_prefix.strip()
3489 @brief Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
3490 @param[in] post_cfg Argument passed to `resolve_post_statistics_output_prefix()`.
3491 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
3492 @param[in] default Argument passed to `resolve_post_statistics_output_prefix()`.
3493 @return Value returned by `resolve_post_statistics_output_prefix()`.
3496 if os.path.isabs(prefix):
3499 if os.path.dirname(prefix):
3503 return os.path.join(CANONICAL_RUN_PATHS[
"statistics"], prefix)
3508 @brief Predict statistics CSV output paths relative to the postprocessor runtime cwd.
3509 @param[in] post_cfg Argument passed to `get_post_statistics_output_artifacts()`.
3510 @param[in] run_dir Argument passed to `get_post_statistics_output_artifacts()`.
3511 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
3512 @return Value returned by `get_post_statistics_output_artifacts()`.
3514 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
3517 "ComputeMSD":
"_msd.csv",
3520 if os.path.isabs(prefix):
3521 base_path = os.path.abspath(prefix)
3523 base_path = os.path.abspath(os.path.join(run_dir, prefix))
3527 suffix = token_to_suffix.get(token)
3529 output_paths.append(base_path + suffix)
3531 return list(dict.fromkeys(output_paths))
3542POST_SPECTRA_TASKS = {
3543 "plane_spectrum": {
"requires_single_block":
True,
"fields": (
"Ucat",)},
3544 "line_spectrum": {
"requires_single_block":
True,
"fields": (
"Ucat",)},
3546 "requires_uniform_cartesian":
True,
3547 "requires_periodic_geometric":
True,
3548 "requires_single_block":
True,
3549 "fields": (
"Ucat",),
3554POST_SPECTRA_SYMBOLS = (
"continuum",
"discrete")
3558POST_SPECTRA_MEAN_MODES = (
"none",
"domain",
"sample")
3562POST_FIELD_STATISTICS_OUTPUTS = (
"mean",
"reynolds_stress",
"rms",
"tke",
"flux",
"point_variance")
3565POST_FIELD_STATISTICS_DEFAULT_OUTPUTS = POST_FIELD_STATISTICS_OUTPUTS[:-1]
3569POST_FIELD_STATISTICS_FORMATS = (
"vtk",
"csv")
3581GRID_MODES = (
"file",
"programmatic_c",
"grid_gen")
3585DOMAIN_DIMENSIONALITY_MODES = (
"3D",
"2D")
3588GRID_GENERATOR_TYPES = (
"box",
"sweep")
3594GRID_CROSS_SECTION_KINDS = (
"rectangle",
"circle")
3598GRID_WALL_SEGMENT_KINDS = (
"flat",
"step",
"ramp",
"arc",
"sine",
"gaussian",
"hill")
3601GRID_PATH_SEGMENT_KINDS = (
"straight",
"arc")
3604GRID_TRANSFORM_KINDS = (
"anchor",
"translate",
"scale",
"rotate",
"mirror",
"permute",
"reverse")
3607PARTICLE_RESTART_MODES = (
"init",
"load")
3610POST_EULERIAN_PIPELINE_TASKS = (
"q_criterion",
"normalize_field",
"nodal_average")
3613POST_LAGRANGIAN_PIPELINE_TASKS = (
"specific_ke",)
3616STUDY_TYPES = (
"grid_independence",
"timestep_independence",
"sensitivity")
3619NEWTON_KRYLOV_PRECONDITIONER_MODELS = (
"none",
"frozen_momentum_jacobian")
3624NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES = (
"none",
"point_block")
3628POISSON_PRECONDITIONER_TYPES = (
"multigrid",)
3631POISSON_PRECONDITIONER_SPELLINGS = {
"mg":
"multigrid",
"pcmg":
"multigrid"}
3637PRESCRIBED_FLOW_SOURCE_TYPES = (
"file",
"generated",
"field_slice")
3640VERIFICATION_SCALAR_PROFILES = (
"CONSTANT",
"LINEAR_X",
"SIN_PRODUCT")
3646SCALAR_TRANSPORT_FLAGS = {
3647 "schmidt_number":
"-schmidt_number",
3648 "turbulent_schmidt_number":
"-turb_schmidt_number",
3649 "iem_constant":
"-iem_constant",
3655SCALAR_TRANSPORT_CASE_PATHS = {
3656 "schmidt_number": (
"properties",
"fluid"),
3657 "turbulent_schmidt_number": (
"models",
"physics",
"turbulence"),
3658 "iem_constant": (
"models",
"physics",
"particles"),
3664 @brief Resolve the scalar-transport parameters from case.yml or their deprecated location.
3665 @details Each parameter is read from its case.yml location (SCALAR_TRANSPORT_CASE_PATHS)
3666 or, as a deprecated alias, from solver.yml `scalar_transport`. Setting one in both
3667 places is refused rather than resolved by precedence.
3668 @param[in] case_cfg Parsed case configuration.
3669 @param[in] solver_cfg Parsed solver configuration.
3670 @param[in] warn Print a deprecation warning for each parameter read from solver.yml.
3671 @return Mapping of parameter name to validated value, for the parameters that are set.
3672 @throws ValueError for a malformed, duplicated, or out-of-range parameter.
3674 legacy = (solver_cfg
or {}).get(
"scalar_transport")
or {}
3675 if not isinstance(legacy, dict):
3676 raise ValueError(
"solver.yml scalar_transport must be a mapping when provided.")
3677 unknown = sorted(set(legacy) - set(SCALAR_TRANSPORT_FLAGS))
3679 raise ValueError(f
"solver.yml scalar_transport has unsupported key(s): {unknown}. "
3680 f
"Use one of {sorted(SCALAR_TRANSPORT_FLAGS)}, in case.yml.")
3682 for key, path
in SCALAR_TRANSPORT_CASE_PATHS.items():
3683 node = case_cfg
or {}
3685 node = node.get(part)
if isinstance(node, dict)
else None
3686 case_label =
".".join(path + (key,))
3687 in_case = isinstance(node, dict)
and key
in node
3688 if in_case
and key
in legacy:
3689 raise ValueError(f
"{key} is set in case.yml ({case_label}) and in its deprecated "
3690 f
"solver.yml location (scalar_transport.{key}); keep only the case.yml one.")
3691 if not in_case
and key
not in legacy:
3693 raw, label = (node[key], f
"case.yml {case_label}")
if in_case
else \
3694 (legacy[key], f
"solver.yml scalar_transport.{key}")
3696 value =
_to_finite_float(raw, label)
if key ==
"iem_constant" else float(raw)
3697 except (TypeError, ValueError)
as exc:
3698 raise ValueError(f
"{label} must be numeric.")
from exc
3700 if value < 0.0
or (value == 0.0
and key !=
"iem_constant"):
3701 raise ValueError(f
"{label} must be {'non-negative' if key == 'iem_constant' else 'positive'}.")
3702 if not in_case
and warn:
3703 print(f
"[WARNING] solver.yml scalar_transport.{key} is deprecated; set it in case.yml "
3704 f
"as {case_label}.", file=sys.stderr)
3709STUDY_PLOT_FORMATS = (
"png",
"pdf",
"svg")
3712PROFILING_TIMESTEP_MODES = (
"off",
"selected",
"all")
3716RENAMED_MONITOR_FUNCTIONS = {
3717 "PoissonSolver_MG":
"PoissonSolver_Multigrid",
3718 "Projection":
"ProjectVelocity",
3719 "PoissonLHSNew":
"AssemblePoissonOperator",
3720 "PoissonRHS":
"ComputePoissonRHS",
3724REMOVED_MONITOR_FUNCTIONS = (
3725 "CorrectChannelFluxProfile",
"VolumeFlux",
"VolumeFlux_rev",
"FullyBlocked",
3726 "MyNvertRestriction",
"MyRestriction",
"MyInterpolation",
"PoissonNullSpaceFunction",
3727 "GridRestriction",
"SetupSolverParameters",
3731GMRES_RESTART_METHODS = (
"gmres",
"fgmres",
"lgmres")
3740POISSON_KSP_METHODS = {
3742 "cg": {
"-ps_ksp_norm_type":
"unpreconditioned"},
3746POISSON_KSP_REFUSED = {
3747 "gmres":
"is not flexible",
3748 "lgmres":
"is not flexible",
3749 "bcgs":
"is not flexible",
3753ANALYTICAL_SOLUTION_TYPES = (
"TGV3D",
"ZERO_FLOW",
"UNIFORM_FLOW")
3757LEGACY_FIELD_INIT_SPELLINGS = (
"Zero",
"Constant",
"Poiseuille")
3760PROJECTION_OPERATORS = (
"continuum",
"picurv_discrete")
3763METRIC_SOURCE_KINDS = (
"statistics_csv",
"csv",
"log_regex",
"log")
3766LAUNCH_MODES = (
"local",
"cluster")
3772 @brief Validate and canonicalize the spectra block of post.yml.
3774 @details The recipe chooses what to measure; whether the case *can* support that
3775 measurement is a separate question answered by
3776 `validate_post_spectra_preconditions()`, which needs the grid and the
3777 boundary conditions. Keeping the two apart means a recipe stays valid on
3778 its own terms even when it is read without a case beside it.
3780 @param[in] post_cfg Parsed post-processing configuration.
3781 @return Normalized mapping with an output prefix and a list of tasks.
3782 @throws ValueError on a malformed or inconsistent recipe.
3784 raw = (post_cfg
or {}).get(
"spectra")
3786 return {
"output_prefix":
"Spectrum",
"tasks": []}
3787 if not isinstance(raw, dict):
3788 raise ValueError(
"'spectra' must be a mapping.")
3790 prefix = raw.get(
"output_prefix",
"Spectrum")
3791 if not isinstance(prefix, str)
or not prefix.strip():
3792 raise ValueError(
"'spectra.output_prefix' must be a non-empty string.")
3794 tasks = raw.get(
"tasks")
3795 if not isinstance(tasks, list)
or not tasks:
3796 raise ValueError(
"'spectra.tasks' must be a non-empty list.")
3800 for position, entry
in enumerate(tasks):
3801 if not isinstance(entry, dict):
3802 raise ValueError(f
"spectra task {position}: each task must be a mapping.")
3803 name = entry.get(
"task")
3804 if not isinstance(name, str)
or name
not in POST_SPECTRA_TASKS:
3806 f
"spectra task {position}: unknown task {name!r}. "
3807 f
"Available tasks: {sorted(POST_SPECTRA_TASKS)}."
3809 spec = POST_SPECTRA_TASKS[name]
3811 field = entry.get(
"field", spec[
"fields"][0])
3812 if field
not in spec[
"fields"]:
3814 f
"spectra task '{name}': field {field!r} is not supported. "
3815 f
"Supported fields: {list(spec['fields'])}."
3818 block = entry.get(
"block", 0)
3819 if not isinstance(block, int)
or isinstance(block, bool)
or block < 0:
3820 raise ValueError(f
"spectra task '{name}': 'block' must be a non-negative integer.")
3822 symbol = entry.get(
"symbol",
"continuum")
3823 if symbol
not in POST_SPECTRA_SYMBOLS:
3825 f
"spectra task '{name}': unknown symbol {symbol!r}. "
3826 f
"Available symbols: {list(POST_SPECTRA_SYMBOLS)}."
3829 subtract_mean = entry.get(
"subtract_mean",
"none")
3830 if not isinstance(subtract_mean, str)
or not subtract_mean.strip():
3831 raise ValueError(f
"spectra task '{name}': 'subtract_mean' must be a string.")
3832 subtract_mean = subtract_mean.strip()
3833 if subtract_mean.startswith(
"window:"):
3834 window_name = subtract_mean.split(
":", 1)[1].strip()
3837 f
"spectra task '{name}': 'subtract_mean: window:<name>' needs a window name."
3839 elif subtract_mean
not in POST_SPECTRA_MEAN_MODES:
3841 f
"spectra task '{name}': unknown subtract_mean {subtract_mean!r}. "
3842 f
"Use one of {list(POST_SPECTRA_MEAN_MODES)} or 'window:<name>'."
3849 mean_source_step = entry.get(
"mean_source_step")
3850 if mean_source_step
is not None:
3851 if (
not isinstance(mean_source_step, int)
or isinstance(mean_source_step, bool)
3852 or mean_source_step < 0):
3854 f
"spectra task '{name}': 'mean_source_step' must be a non-negative integer."
3856 if not subtract_mean.startswith(
"window:"):
3858 f
"spectra task '{name}': 'mean_source_step' only applies to "
3859 f
"'subtract_mean: window:<name>'."
3862 reference = entry.get(
"reference")
3863 if reference
is not None and (
not isinstance(reference, str)
or not reference.strip()):
3864 raise ValueError(f
"spectra task '{name}': 'reference' must be a non-empty string.")
3867 if name !=
"shell_spectrum":
3868 axes = entry.get(
"axes")
3869 fixed = entry.get(
"fixed_indices")
3870 count = 2
if name ==
"plane_spectrum" else 1
3871 if (
not isinstance(axes, list)
or len(axes) != count
or any(a
not in (
"i",
"j",
"k")
for a
in axes)
3872 or len(set(axes)) != count
or not isinstance(fixed, dict)
3873 or set(fixed) != set(
"ijk")-set(axes)
3874 or any(isinstance(i, bool)
or not isinstance(i, int)
or i < 0
for i
in fixed.values())):
3875 raise ValueError(f
"{name} requires {count} distinct axes and zero-based fixed_indices on all remaining axes.")
3876 if symbol !=
"continuum":
3877 raise ValueError(
"plane/line spectra require symbol: continuum.")
3878 selection = {
"axes": sorted(axes),
"fixed_indices": dict(sorted(fixed.items()))}
3879 elif subtract_mean ==
"sample" or "axes" in entry
or "fixed_indices" in entry:
3880 raise ValueError(
"shell_spectrum does not accept sample subtraction or plane/line selections.")
3882 identity = (name, field, block, symbol, json.dumps(selection, sort_keys=
True))
3883 if identity
in seen:
3885 f
"spectra recipe lists task '{name}' for field {field} on block {block} "
3886 f
"with symbol '{symbol}' more than once."
3888 seen.append(identity)
3895 "subtract_mean": subtract_mean,
3896 "mean_source_step": mean_source_step,
3897 "reference": reference.strip()
if isinstance(reference, str)
else None,
3900 return {
"output_prefix": prefix.strip(),
"tasks": cleaned}
3905 @brief Check spectra tasks against what the case can actually support.
3907 @details Runs before any field is read, so a case with no homogeneous direction is
3908 refused rather than yielding a curve that means nothing. Only the checks
3909 the case file can answer are made here: periodicity and block count. Grid
3910 uniformity is enforced by `generators/spectra.gen`, which reads the staged
3911 PICGRID and is the only place the real node coordinates are known.
3913 @param[in] spectra_cfg Normalized spectra recipe.
3914 @param[in] case_cfg Parsed case configuration.
3915 @param[in] post_path Post recipe path, for error messages.
3916 @return List of formatted error strings; empty when every task is supportable.
3919 if not spectra_cfg.get(
"tasks"):
3928 block_count = int((case_cfg.get(
"models", {})
or {}).get(
"domain", {}).get(
"blocks", 1))
3930 for block_index, block_bcs
in enumerate(prepared_blocks):
3931 faces = {entry.get(
"face")
for entry
in block_bcs
if entry.get(
"type") ==
"PERIODIC"}
3932 periodic_faces[block_index] = faces
3936 grid_mode = str((case_cfg.get(
"grid", {})
or {}).get(
"mode",
"")).strip()
3937 if grid_mode ==
"programmatic_c":
3939 f
" {post_path}: spectra need a staged PICGRID, which grid.mode 'programmatic_c' "
3940 "does not provide. Use grid.mode 'grid_gen' or 'file' for a case whose spectra "
3945 all_faces = {
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta"}
3946 for task_cfg
in spectra_cfg[
"tasks"]:
3947 spec = POST_SPECTRA_TASKS[task_cfg[
"task"]]
3948 label = f
"spectra task '{task_cfg['task']}'"
3950 if spec.get(
"requires_single_block")
and block_count != 1:
3952 f
" {post_path}: {label} requires a single-block domain; this case has "
3953 f
"{block_count} blocks."
3956 block = task_cfg[
"block"]
3957 if block >= block_count:
3959 f
" {post_path}: {label} targets block {block}, but the case defines "
3960 f
"{block_count} block(s)."
3964 if task_cfg[
"task"] !=
"shell_spectrum":
3965 if (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"fsi", {})
or {}).get(
"immersed",
False):
3966 errors.append(f
" {post_path}: {label} does not support immersed masks.")
3967 names = {
"i":
"Xi",
"j":
"Eta",
"k":
"Zeta"}
3968 needed = {sign+names[a]
for a
in task_cfg[
"axes"]
for sign
in (
"-",
"+")}
3969 if not needed <= periodic_faces.get(block, set()):
3970 errors.append(f
" {post_path}: {label} requires PERIODIC boundaries on its transform axes.")
3971 if spec.get(
"requires_periodic_geometric"):
3972 missing = sorted(all_faces - periodic_faces.get(block, set()))
3975 f
" {post_path}: {label} requires every face of block {block} to be "
3976 f
"PERIODIC, because a shell-averaged spectrum is only defined for a "
3977 f
"triply periodic homogeneous box. Non-periodic faces: {missing}."
3984 @brief Locate one checkpoint payload by its inventory entry rather than by path shape.
3985 @param[in] bundle Validated checkpoint bundle mapping.
3986 @param[in] kind Payload kind recorded in the inventory.
3987 @param[in] field Payload field name recorded in the inventory.
3988 @param[in] block Block index the payload belongs to.
3989 @return Absolute path to the payload file.
3990 @throws ValueError when the bundle carries no such payload.
3992 for payload
in bundle[
"payloads"]:
3993 if (payload[
"kind"] == kind
and payload[
"field"] == field
3994 and payload[
"block"] == str(block)):
3995 return os.path.join(bundle[
"bundle"], *payload[
"path"].split(
"/"))
3997 f
"checkpoint {os.path.basename(bundle['bundle'])} carries no {kind} payload "
3998 f
"'{field}' for block {block}."
4004 @brief Build the generator arguments implementing a task's fluctuation choice.
4005 @param[in] task_cfg Normalized spectra task.
4006 @param[in] bundle Validated checkpoint bundle for the step being transformed.
4007 @param[in] mean_bundle Bundle supplying the window mean; defaults to @p bundle.
4008 @return Argument list to append to the generator command.
4009 @throws ValueError when a named window is absent from the bundle.
4011 mode = task_cfg[
"subtract_mean"]
4012 if not mode.startswith(
"window:"):
4013 return [
"--subtract-mean", mode]
4014 window = mode.split(
":", 1)[1].strip()
4015 block = task_cfg[
"block"]
4016 source = mean_bundle
or bundle
4019 return [
"--subtract-mean",
"field",
"--mean-file", mean_path,
"--count-file", count_path]
4023POST_SPECTRA_SCALAR_COLUMNS = (
4024 "resolved_kinetic_energy",
"spectrum_total_energy",
"parseval_residual",
4025 "spectrum_peak_k",
"zero_mode_energy",
"integral_length_scale",
4026 "taylor_microscale",
"dissipation_over_viscosity",
4031POST_STAGE_NAMES = (
"fields",
"spectra",
"reducers")
4036 @brief Resolve the `--only` selector into the set of post stages to execute.
4037 @param[in] only Comma-separated selector text, or None for every stage.
4038 @return Set of stage names.
4039 @throws ValueError when the selector names an unknown or empty stage.
4042 return set(POST_STAGE_NAMES)
4043 requested = [token.strip()
for token
in str(only).split(
",")]
4044 if not all(requested):
4045 raise ValueError(
"--only must not contain an empty stage name.")
4046 unknown = sorted({token
for token
in requested
if token
not in POST_STAGE_NAMES})
4049 f
"--only names unknown post stage(s) {unknown}. "
4050 f
"Available stages: {list(POST_STAGE_NAMES)}."
4052 return set(requested)
4056 source_dir: str, steps, quiet: bool =
False,
4057 plan:
"dict | None" =
None, recompute: bool =
False) -> dict:
4059 @brief Measure spectra for every requested task across a window of committed steps.
4061 @details One spectrum per step per task, because a spectrum is a property of one
4062 state: averaging across steps would be wrong for a decaying flow, where
4063 every step is a different statistical state. Results are written in long
4064 form so a family of curves stays one file, alongside a scalar history that
4065 plots through the ordinary series machinery.
4067 @param[in] run_dir Run directory receiving the output.
4068 @param[in] post_cfg Parsed post-processing configuration.
4069 @param[in] monitor_cfg Parsed monitor configuration anchoring the output root.
4070 @param[in] source_dir Directory holding the committed checkpoints.
4071 @param[in] steps Iterable of checkpoint steps to process, in order.
4072 @param[in] quiet Suppress progress reporting.
4073 @param[in] plan Post plan whose recipe state records each measured step; with it, a
4074 step whose rows are still valid (classify_post_step()) is not
4075 measured again. None measures every committed step given.
4076 @param[in] recompute Measure every committed step even when its rows are valid.
4077 @return Summary with the written paths and the steps actually processed.
4078 @throws ValueError when the generator fails or a requested payload is absent.
4081 if not spectra[
"tasks"]:
4082 return {
"tasks": [],
"steps": [],
"artifacts": []}
4084 script = os.path.join(GENERATORS_PATH,
"spectra.gen")
4085 if not os.path.isfile(script):
4086 raise ValueError(f
"spectra.gen script not found: {script}")
4087 staged_grid = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
4088 if not os.path.isfile(staged_grid):
4089 raise ValueError(f
"spectra require a staged PICGRID at {staged_grid}.")
4091 output_dir = os.path.join(
4094 os.makedirs(output_dir, exist_ok=
True)
4099 scale_arguments = []
4102 if bool((post_cfg.get(
"global_operations")
or {}).get(
"dimensionalize",
False)):
4104 case_path = os.path.join(run_dir, active_case)
if active_case
else None
4105 if case_path
and os.path.isfile(case_path):
4108 "--velocity-ref", repr(float(scaling[
"velocity_ref"])),
4109 "--length-ref", repr(float(scaling[
"length_ref"])),
4113 print(
"[WARNING] Spectra: dimensionalize was requested but this run carries no "
4114 "readable case snapshot; results stay non-dimensional.", file=sys.stderr)
4116 requested = sorted(set(int(step)
for step
in steps))
4119 if plan
is not None:
4126 os.path.join(output_dir, f
"{post_spectra_task_basename(task, spectra['output_prefix'])}{suffix}.csv")
4127 for task
in spectra[
"tasks"]
for suffix
in (
"",
"_history")
4131 for step
in requested:
4132 record = existing.get(str(step))
4135 commit, signature, recompute)
4136 if reason
is not None:
4137 pending.append(step)
4138 elif record
is None:
4139 records[step] = {
"recipe": signature,
"checkpoint": commit[0]
if commit
else None}
4140 if not quiet
and len(pending) != len(requested):
4141 print(f
"[INFO] Spectra: {len(requested) - len(pending)} requested step(s) are up to date.")
4143 require_storage_payload_local(run_dir,
"spectra", checkpoints=pending)
4144 except StorageError
as exc:
4145 raise ValueError(str(exc))
from exc
4149 return {
"tasks": [],
"steps": [],
"artifacts": []}
4154 ordered_steps = [step
for step
in requested
if step
in available]
4155 if not ordered_steps:
4157 print(
"[INFO] Spectra: no step needing measurement has a committed checkpoint "
4158 "yet; nothing measured.")
4159 if plan
is not None:
4161 return {
"tasks": [],
"steps": [],
"artifacts": []}
4162 if not quiet
and len(ordered_steps) != len(requested):
4163 print(f
"[INFO] Spectra: {len(ordered_steps)} of {len(requested)} step(s) needing "
4164 f
"measurement are committed; measuring those.")
4166 for task_cfg
in spectra[
"tasks"]:
4168 spectrum_path = os.path.join(output_dir, f
"{basename}.csv")
4169 scalar_path = os.path.join(output_dir, f
"{basename}_history.csv")
4173 if task_cfg[
"mean_source_step"]
is not None:
4176 for step
in ordered_steps:
4178 time = float(bundle[
"metadata"][
"checkpoint_time"]) * time_scale
4180 bundle,
"eulerian", task_cfg[
"field"], task_cfg[
"block"]
4182 cmd = [sys.executable, script, task_cfg[
"task"].replace(
"_",
"-"),
4183 "--field-file", field_path,
"--source-grid", staged_grid,
4184 "--block", str(task_cfg[
"block"]),
"--symbol", task_cfg[
"symbol"]]
4185 if task_cfg[
"task"] !=
"shell_spectrum":
4186 cmd.extend([
"--axes", *task_cfg[
"axes"],
"--fixed-indices", json.dumps(task_cfg[
"fixed_indices"])])
4188 cmd.extend(scale_arguments)
4190 result = subprocess.run(cmd, text=
True, capture_output=
True)
4191 if result.returncode != 0:
4192 details = (result.stderr
or result.stdout
or "").strip()
4194 f
"spectra task '{task_cfg['task']}' failed at step {step} with exit code "
4195 f
"{result.returncode}. Details:\n{details}"
4197 summary = json.loads(result.stdout)
4198 for row
in summary.get(
"sampled_spectrum", summary.get(
"shell_spectrum")):
4199 spectrum_rows.append({
"step": step,
"time": time, **row})
4200 scalar_columns = (POST_SPECTRA_SCALAR_COLUMNS
if task_cfg[
"task"] ==
"shell_spectrum"
4201 else (
"resolved_kinetic_energy",
"parseval_residual"))
4202 scalar_rows.append({
"step": step,
"time": time,
4203 **{name: summary[name]
for name
in scalar_columns}})
4205 spectrum_fields = (
"step",
"time", *summary.get(
"spectrum_columns", (
"k",
"energy")))
4206 scalar_fields = (
"step",
"time") + scalar_columns
4209 remeasured = set(ordered_steps)
4210 spectrum_rows =
_merge_spectra_rows(spectrum_path, spectrum_fields, remeasured, spectrum_rows)
4212 with open(spectrum_path,
"w", newline=
"", encoding=
"utf-8")
as stream:
4213 writer = csv.DictWriter(stream, fieldnames=spectrum_fields)
4214 writer.writeheader()
4215 writer.writerows(spectrum_rows)
4216 with open(scalar_path,
"w", newline=
"", encoding=
"utf-8")
as stream:
4217 writer = csv.DictWriter(stream, fieldnames=scalar_fields)
4218 writer.writeheader()
4219 writer.writerows(scalar_rows)
4220 artifacts.extend([spectrum_path, scalar_path])
4222 print(f
"[INFO] Spectra: measured {len(ordered_steps)} step(s) for task "
4223 f
"'{task_cfg['task']}'; {os.path.relpath(spectrum_path, run_dir)} now holds "
4224 f
"{len(scalar_rows)} step(s)")
4226 if plan
is not None:
4227 for step
in ordered_steps:
4229 records[step] = {
"recipe": signature,
"checkpoint": commit[0]
if commit
else None}
4232 "tasks": [entry[
"task"]
for entry
in spectra[
"tasks"]],
4233 "steps": ordered_steps,
4234 "artifacts": artifacts,
4242POST_REDUCER_TASKS = {
4243 "wall_profile": {
"command":
"wall-profile",
"retains": 1,
"axis_key":
"wall_axis",
"default_axis":
"j"},
4244 "cross_section": {
"command":
"cross-section",
"retains": 2,
"axis_key":
"stream_axis",
"default_axis":
"k"},
4245 "profile_series": {
"command":
"profile-series",
"retains": 1,
"axis_key":
None,
"default_axis":
None},
4251POST_REDUCER_FRICTION_SOURCES = {
4252 "first_cell": {
"needs":
None},
4253 "driven_flow": {
"needs":
"window"},
4254 "wall_model": {
"needs":
"window"},
4255 "body_force": {
"needs":
"value"},
4256 "u_tau": {
"needs":
"value"},
4260_REDUCER_AXIS_TOKENS = {
"i":
"Xi",
"j":
"Eta",
"k":
"Zeta"}
4265 @brief Validate and canonicalize the reducers block of post.yml.
4266 @details A reducer reads the profiles `field_statistics` writes, so each task names a
4267 window or ensemble of the same recipe, and the recipe's `homogeneous` must
4268 leave the directions the task needs.
4269 @param[in] post_cfg Parsed post-processing configuration.
4270 @return Mapping with the canonical task list.
4272 raw = (post_cfg
or {}).get(
"reducers")
4274 return {
"tasks": []}
4275 if not isinstance(raw, dict)
or not isinstance(raw.get(
"tasks"), list)
or not raw[
"tasks"]:
4276 raise ValueError(
"'reducers' must be a mapping with a non-empty 'tasks' list.")
4278 windows = set(statistics[
"windows"])
4279 owners = windows | {ensemble[
"name"]
for ensemble
in statistics[
"ensembles"]}
4280 retained = [axis
for axis
in (
"i",
"j",
"k")
if axis
not in statistics[
"homogeneous"]]
4282 for index, entry
in enumerate(raw[
"tasks"]):
4283 if not isinstance(entry, dict):
4284 raise ValueError(f
"'reducers.tasks[{index}]' must be a mapping.")
4285 name = entry.get(
"task")
4286 if not isinstance(name, str)
or name
not in POST_REDUCER_TASKS:
4287 raise ValueError(f
"reducer task {name!r} is unknown. Available tasks: {sorted(POST_REDUCER_TASKS)}.")
4288 spec = POST_REDUCER_TASKS[name]
4289 label = f
"reducer '{name}'"
4290 source = entry.get(
"source")
4291 if not isinstance(source, str)
or source
not in owners:
4292 raise ValueError(f
"{label}: 'source' must name a field_statistics window or ensemble of this "
4293 f
"recipe (got {source!r}; available: {sorted(owners)}).")
4294 if "csv" not in statistics[
"formats"]
or len(retained) != spec[
"retains"]:
4295 raise ValueError(f
"{label} reads a profile keeping {spec['retains']} direction(s), so "
4296 f
"field_statistics needs 'csv' in formats and 'homogeneous' leaving that "
4297 f
"many (it leaves {retained}).")
4298 task = {
"task": name,
"source": source}
4299 if spec[
"axis_key"]:
4300 axis = entry.get(spec[
"axis_key"], spec[
"default_axis"])
4301 if axis
not in _REDUCER_AXIS_TOKENS:
4302 raise ValueError(f
"{label}: '{spec['axis_key']}' must be one of i, j, k (got {axis!r}).")
4303 kept = axis
in retained
4304 if kept != (spec[
"axis_key"] ==
"wall_axis"):
4305 raise ValueError(f
"{label}: the profile {'must keep' if not kept else 'must merge over'} "
4306 f
"'{axis}', but 'homogeneous' is {statistics['homogeneous']}.")
4307 task[spec[
"axis_key"]] = axis
4308 stream = entry.get(
"stream_axis",
"k")
4309 if stream
not in _REDUCER_AXIS_TOKENS
or stream == task.get(
"wall_axis"):
4310 raise ValueError(f
"{label}: 'stream_axis' must be i, j or k and differ from the wall axis.")
4311 task[
"stream_axis"] = stream
4312 friction = entry.get(
"friction_velocity",
"first_cell")
4313 if friction
not in POST_REDUCER_FRICTION_SOURCES
or (name ==
"cross_section" and friction ==
"wall_model"):
4314 raise ValueError(f
"{label}: 'friction_velocity' must be one of "
4315 f
"{[f for f in POST_REDUCER_FRICTION_SOURCES if name != 'cross_section' or f != 'wall_model']} "
4316 f
"(got {friction!r}).")
4317 needs = POST_REDUCER_FRICTION_SOURCES.get(friction, {}).get(
"needs")
4318 if needs ==
"window" and source
not in windows:
4319 raise ValueError(f
"{label}: friction_velocity '{friction}' averages over a run window's time "
4320 f
"bounds, so 'source' must be a run window, not an ensemble.")
4321 task[
"friction_velocity"] = friction
4322 if needs ==
"value":
4323 value = entry.get(
"value")
4324 if not isinstance(value, (int, float))
or isinstance(value, bool)
or value <= 0:
4325 raise ValueError(f
"{label}: friction_velocity '{friction}' needs a positive 'value'.")
4326 task[
"value"] = float(value)
4327 if name ==
"cross_section":
4328 task[
"fold"] = bool(entry.get(
"fold",
True))
4329 if name ==
"wall_profile" and entry.get(
"half_height")
is not None:
4330 task[
"half_height"] = float(entry[
"half_height"])
4332 return {
"tasks": tasks}
4337 @brief Run every reducer task over the profiles the recipe has written.
4338 @details Reducers read only small CSVs, so every profile present is reduced again on
4339 each invocation rather than tracked step by step. Inputs come from the run:
4340 the viscosity and reference scales from its case snapshot, the grid it was
4341 staged with, a window's effective bounds from each step's `checkpoint.meta`,
4342 and the runtime metrics CSVs.
4343 @param[in] run_dir Run directory.
4344 @param[in] post_cfg Post recipe with canonical paths applied.
4345 @param[in] quiet Suppress progress reporting.
4346 @return Absolute paths written.
4347 @throws ValueError when a reducer fails.
4352 run_dir = os.path.abspath(run_dir)
4353 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
4355 script = os.path.join(GENERATORS_PATH,
"reduce.gen")
4356 grid = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
4358 case_cfg =
read_yaml_file(case_path)
if case_path
and os.path.isfile(case_path)
else {}
4360 dimensional = bool((post_cfg.get(
"global_operations")
or {}).get(
"dimensionalize",
False))
4361 velocity_scale = scaling[
"velocity_ref"]
if dimensional
else 1.0
4362 dt_physical = float((case_cfg.get(
"run_control")
or {}).get(
"dt_physical", 0.0)
or 0.0)
4363 step_time = dt_physical
if dimensional
else dt_physical /
reference_scale(TIME, scaling)
4364 prefix = os.path.join(run_dir, post_cfg[
"_picurv_paths"][
"field_statistics_prefix"])
4365 output_root = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"])
4369 def lineage_metrics(name):
4371 @brief List a metrics CSV across the restart lineage, root to leaf.
4372 @details A window carried into a branched run spans its ancestors' time, whose
4373 metrics rows are in their own files. Each ancestor's file is limited to
4374 the steps up to the checkpoint its child branched from.
4375 @param[in] name Metrics CSV basename.
4376 @return `path` or `path@last_step` entries for every segment holding the file.
4379 for index, segment
in enumerate(lineage):
4380 path = os.path.join(segment[
"run_dir"], CANONICAL_RUN_PATHS[
"metrics"], name)
4381 if not os.path.isfile(path):
4383 child = lineage[index + 1]
if index + 1 < len(lineage)
else None
4384 entries.append(f
"{path}@{child['fork_step']}" if child
else path)
4385 return entries
or [os.path.join(run_dir, CANONICAL_RUN_PATHS[
"metrics"], name)]
4387 def run(arguments, label):
4389 @brief Run one reduce.gen subcommand, failing with its own diagnostics.
4390 @param[in] arguments Subcommand and its arguments.
4391 @param[in] label Reducer and step named in a failure.
4393 result = subprocess.run([sys.executable, script, *arguments], text=
True, capture_output=
True)
4394 if result.returncode != 0:
4395 raise ValueError(f
"reducer {label} failed with exit code {result.returncode}:\n"
4396 f
"{(result.stderr or result.stdout).strip()}")
4399 family = f
"{prefix}_profile_{task['source']}"
4400 if task[
"task"] ==
"profile_series":
4401 output = f
"{family}_history.csv"
4403 run([
"profile-series",
"--profile-prefix", family,
"--dt", repr(step_time),
4404 "--output", output], f
"profile_series of '{task['source']}'")
4405 written.append(output)
4407 friction = {
"first_cell": [],
"body_force": [
"--body-force", repr(task.get(
"value"))],
4408 "u_tau": [
"--u-tau", repr(task.get(
"value"))],
4409 "driven_flow": [
"--driven-flow-csv", *lineage_metrics(
"driven_flow.csv")],
4410 "wall_model": [
"--wall-model-csv", *lineage_metrics(
"wall_model.csv")]}[task[
"friction_velocity"]]
4412 arguments = [POST_REDUCER_TASKS[task[
"task"]][
"command"],
"--profile", path,
"--grid", grid,
4413 "--window", task[
"source"],
"--viscosity",
4414 repr(scaling[
"nondimensional_kinematic_viscosity"]),
4415 "--velocity-scale", repr(velocity_scale),
4416 "--stream-axis", _REDUCER_AXIS_TOKENS[task[
"stream_axis"]], *friction]
4419 for index
in range(int(options.get(
"checkpoint_statistics_window_count", 0)
or 0)):
4420 key = f
"checkpoint_statistics_window_{index}_"
4421 if options.get(key +
"name") == task[
"source"]:
4422 arguments += [
"--window-start", options[key +
"effective_start"],
4423 "--window-end", options[key +
"effective_end"],
4424 "--window-note", f
"{options.get(key + 'state')}, "
4425 f
"{options.get(key + 'sample_count')} samples"]
4426 if task[
"task"] ==
"wall_profile":
4427 output = f
"{prefix}_wall_profile_{task['source']}_{step}.csv"
4428 arguments += [
"--wall-axis", _REDUCER_AXIS_TOKENS[task[
"wall_axis"]],
"--output", output]
4429 if "half_height" in task:
4430 arguments += [
"--half-height", repr(task[
"half_height"])]
4431 written.append(output)
4433 output = f
"{prefix}_cross_section_{task['source']}_{step}"
4434 arguments += [
"--output-prefix", output] + ([]
if task[
"fold"]
else [
"--no-fold"])
4435 written.append(output +
"_cross_section.csv")
4436 run(arguments, f
"{task['task']} of '{task['source']}' at step {step}")
4438 for path
in written:
4439 print(f
"[INFO] Reducer output: {os.path.relpath(path)}")
4445 @brief Combine newly measured spectra rows with those already written for other steps.
4446 @details The file belongs to one recipe, whose identity covers the spectra settings, so
4447 its existing rows come from the same measurement. Rows for a re-measured step
4448 are replaced; rows for every other step are kept. A file whose columns differ
4449 is not merged: it was written by another format and is rewritten whole.
4450 @param[in] path Existing CSV path, which may be absent.
4451 @param[in] fieldnames Columns the new rows carry.
4452 @param[in] remeasured Steps measured in this invocation.
4453 @param[in] new_rows Rows measured in this invocation.
4454 @return All rows, ordered by step with each step's rows in their measured order.
4458 with open(path, newline=
"", encoding=
"utf-8")
as stream:
4459 reader = csv.DictReader(stream)
4460 if tuple(reader.fieldnames
or ()) == tuple(fieldnames):
4461 kept = [row
for row
in reader
if int(row[
"step"])
not in remeasured]
4462 except FileNotFoundError:
4464 return sorted(kept +
list(new_rows), key=
lambda row: int(row[
"step"]))
4469 @brief Reduce a normalized spectra recipe to a stable identity string.
4470 @param[in] spectra_cfg Normalized spectra recipe.
4471 @return Hex digest covering every choice that changes the produced spectra.
4473 payload = json.dumps(spectra_cfg, sort_keys=
True, separators=(
",",
":")).encode(
"utf-8")
4474 return hashlib.sha256(payload).hexdigest()[:16]
4479 @brief Resolve the run-relative directory spectra CSVs are written to.
4480 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
4481 @return Run-relative directory path.
4484 return CANONICAL_RUN_PATHS[
"spectra"]
4489 @brief Resolve the canonical spectra directory for one versioned recipe.
4490 @param[in] post_cfg Parsed runtime post config.
4491 @param[in] monitor_cfg Optional monitor config for standalone compatibility.
4492 @return Run-relative spectra directory.
4494 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
4500 @brief Predict the spectra CSV paths a recipe will write.
4501 @param[in] post_cfg Parsed post-processing configuration.
4502 @param[in] run_dir Run directory the recipe operates on.
4503 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
4504 @return Absolute CSV paths, one per task, in recipe order.
4511 if not spectra[
"tasks"]:
4515 for task_cfg
in spectra[
"tasks"]:
4517 paths.append(os.path.join(base, f
"{name}.csv"))
4518 return list(dict.fromkeys(paths))
4523 @brief Build the file basename one normalized spectra task writes.
4524 @param[in] task_cfg Normalized task mapping.
4525 @param[in] output_prefix Recipe output prefix.
4526 @return Basename without directory or extension.
4528 parts = [output_prefix, task_cfg[
"task"], task_cfg[
"field"],
4529 f
"block{task_cfg['block']:04d}", task_cfg[
"symbol"]]
4530 if task_cfg[
"task"] !=
"shell_spectrum":
4531 parts.extend([
"".join(task_cfg[
"axes"]), *[f
"{a}{i}" for a,i
in task_cfg[
"fixed_indices"].items()]])
4532 return "_".join(parts)
4537 @brief Validate and canonicalize the ensembles a post recipe defines.
4538 @details An ensemble is a window over one instant: its samples are the particles in
4539 each cell, or each cell's own state, at the step being processed. It is
4540 defined in the recipe rather than the run, because it needs no state
4541 accumulated during the solve.
4542 @param[in] raw The recipe's `field_statistics.ensembles` list, or None.
4543 @return List of ensembles with name, source, fields and covariances.
4547 if not isinstance(raw, list):
4548 raise ValueError(
"'field_statistics.ensembles' must be a list.")
4550 for index, entry
in enumerate(raw):
4551 if not isinstance(entry, dict):
4552 raise ValueError(f
"'field_statistics.ensembles[{index}]' must be a mapping.")
4553 name = entry.get(
"name")
4554 if not isinstance(name, str)
or not name.strip():
4555 raise ValueError(f
"'field_statistics.ensembles[{index}]' needs a non-empty 'name'.")
4557 if any(existing[
"name"] == name
for existing
in ensembles):
4558 raise ValueError(f
"field statistics ensemble '{name}' is defined more than once.")
4559 source = entry.get(
"source")
4560 if source
not in POST_STATISTICS_ENSEMBLE_SOURCES:
4562 f
"field statistics ensemble '{name}': 'source' must be one of "
4563 f
"{list(POST_STATISTICS_ENSEMBLE_SOURCES)} (got {source!r})."
4566 f
"field statistics ensemble '{name}'", entry.get(
"fields"), entry.get(
"covariances"),
4568 ensembles.append({
"name": name,
"source": source,
"fields": fields,
"covariances": covariances})
4574 @brief Validate and canonicalize the field_statistics block of post.yml.
4576 @details The recipe names windows rather than redescribing them, because the
4577 window definitions already reach the post-processor through the run's
4578 solver control. Validation therefore covers the recipe's own choices
4579 only; a name no window matches is caught in C against the resolved list,
4580 which is the only place the real set is known.
4582 @param[in] post_cfg Parsed post-processing configuration.
4583 @return Value returned by `normalize_post_field_statistics_config()`.
4585 raw = (post_cfg
or {}).get(
"field_statistics")
4587 return {
"windows": [],
"ensembles": [],
"homogeneous": [],
"source_step":
None,
4588 "outputs": [],
"formats": []}
4589 if not isinstance(raw, dict):
4590 raise ValueError(
"'field_statistics' must be a mapping.")
4593 windows = raw.get(
"windows", []
if ensembles
else None)
4594 if not isinstance(windows, list)
or not (windows
or ensembles):
4595 raise ValueError(
"'field_statistics.windows' must be a non-empty list of window names "
4596 "unless 'ensembles' defines what to derive.")
4598 for entry
in windows:
4599 if not isinstance(entry, str)
or not entry.strip():
4600 raise ValueError(
"'field_statistics.windows' entries must be non-empty window names.")
4601 name = entry.strip()
4604 raise ValueError(f
"field statistics recipe lists window '{name}' more than once.")
4605 cleaned.append(name)
4606 for ensemble
in ensembles:
4607 if ensemble[
"name"]
in cleaned:
4608 raise ValueError(f
"field statistics ensemble '{ensemble['name']}' has the name of a "
4609 "requested window; both would write the same files.")
4611 label =
", ".join(cleaned + [ensemble[
"name"]
for ensemble
in ensembles])
4612 source_step = raw.get(
"source_step")
4613 if source_step
is not None:
4614 if not isinstance(source_step, int)
or isinstance(source_step, bool)
or source_step < 0:
4616 f
"field statistics recipe for [{label}]: 'source_step' must be a non-negative "
4617 f
"integer (got {source_step!r})."
4620 outputs = raw.get(
"outputs",
list(POST_FIELD_STATISTICS_DEFAULT_OUTPUTS))
4621 if not isinstance(outputs, list)
or not outputs:
4622 raise ValueError(f
"field statistics recipe for [{label}]: 'outputs' must be a non-empty list.")
4623 unknown = [item
for item
in outputs
if item
not in POST_FIELD_STATISTICS_OUTPUTS]
4626 f
"field statistics recipe for [{label}]: unknown outputs {unknown}. "
4627 f
"Available outputs: {list(POST_FIELD_STATISTICS_OUTPUTS)}."
4629 if len(set(outputs)) != len(outputs):
4630 raise ValueError(f
"field statistics recipe for [{label}]: 'outputs' lists a duplicate.")
4632 formats = raw.get(
"formats", [
"vtk"])
4633 if not isinstance(formats, list)
or not formats:
4634 raise ValueError(f
"field statistics recipe for [{label}]: 'formats' must be a non-empty list.")
4635 unknown = [item
for item
in formats
if item
not in POST_FIELD_STATISTICS_FORMATS]
4638 f
"field statistics recipe for [{label}]: unknown formats {unknown}. "
4639 f
"Available formats: {list(POST_FIELD_STATISTICS_FORMATS)}."
4641 if len(set(formats)) != len(formats):
4642 raise ValueError(f
"field statistics recipe for [{label}]: 'formats' lists a duplicate.")
4644 homogeneous = raw.get(
"homogeneous", [])
4645 if (
not isinstance(homogeneous, list)
or any(axis
not in (
"i",
"j",
"k")
for axis
in homogeneous)
4646 or len(set(homogeneous)) != len(homogeneous)):
4648 f
"field statistics recipe for [{label}]: 'homogeneous' must list distinct axes from "
4649 f
"['i', 'j', 'k'] (got {homogeneous!r})."
4652 if ensembles
and "csv" in formats
and not homogeneous:
4654 f
"field statistics recipe for [{label}]: an ensemble writes CSV only as a profile, "
4655 "so 'csv' needs 'homogeneous' to name the directions to merge over."
4660 "ensembles": ensembles,
4661 "homogeneous": [axis
for axis
in (
"i",
"j",
"k")
if axis
in homogeneous],
4662 "source_step": source_step,
4663 "outputs":
list(outputs),
4664 "formats":
list(formats),
4670 @brief Count the derived fields one window would produce for a set of outputs.
4672 @details Mirrors the resolution the C derivation performs, so a recipe that would
4673 produce an empty file is refused before the run rather than after it. Each
4674 output resolves against what the window accumulated: a window keeping only
4675 first moments has no stresses, RMS, or turbulent kinetic energy to give.
4677 @param[in] window_cfg Normalized window definition from monitor.yml.
4678 @param[in] outputs Requested output kinds.
4679 @param[in] eligible Field table the window's fields are named from; the Eulerian
4681 @return Value returned by `_post_window_derived_field_count()`.
4683 eligible = STATISTICS_ELIGIBLE_FIELDS
if eligible
is None else eligible
4684 fields = window_cfg.get(
"fields", [])
or []
4685 covariances = window_cfg.get(
"covariances", [])
or []
4686 with_second = [f
for f
in fields
if "second" in (f.get(
"moments")
or [])]
4688 for kind
in outputs:
4690 total += len(fields)
4691 elif kind ==
"reynolds_stress":
4692 for entry
in with_second:
4693 dof = eligible[entry[
"field"]][
"components"]
4694 total += 6
if dof == 3
else 1
4696 for entry
in with_second:
4697 total += eligible[entry[
"field"]][
"components"]
4699 total += sum(1
for entry
in with_second
4700 if eligible[entry[
"field"]][
"components"] == 3)
4701 elif kind ==
"flux":
4702 total += len(covariances)
4708 @brief Return whether the current post recipe derives accumulated field statistics.
4709 @param[in] post_cfg Argument passed to `_post_requests_field_statistics()`.
4710 @return Value returned by `_post_requests_field_statistics()`.
4714 return bool(config[
"windows"]
or config[
"ensembles"])
4722 @brief Predict the per-window statistics artifacts a recipe will produce.
4723 @details Returns one entry per window and format, so resume tracking can tell a
4724 half-finished window from a completed one.
4725 @param[in] post_cfg Parsed post-processing configuration.
4726 @param[in] run_dir Run directory the outputs are written under.
4727 @return List of (kind, path_prefix, window) tuples; kind is 'vtk', 'csv' (a history
4728 shared by every step) or 'profile' (a prefix taking `_<step>.csv`). window is
4729 None for a recipe ensemble, which every processed step produces.
4732 if not (config[
"windows"]
or config[
"ensembles"]):
4734 io_cfg = post_cfg.get(
"io", {})
or {}
4735 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
4737 visualization_prefix = io_cfg.get(
"output_filename_prefix",
"Field")
4738 csv_prefix_path =
None
4739 if internal.get(
"field_statistics_prefix"):
4740 csv_prefix_path = os.path.join(run_dir, internal[
"field_statistics_prefix"])
4741 csv_base = csv_prefix_path
or os.path.join(visualization_dir, str(visualization_prefix))
4745 owners = [(window, window)
for window
in config[
"windows"]]
4746 owners += [(ensemble[
"name"],
None)
for ensemble
in config[
"ensembles"]]
4747 for name, window
in owners:
4748 if "vtk" in config[
"formats"]:
4749 artifacts.append((
"vtk", os.path.join(
4750 visualization_dir, f
"{visualization_prefix}_statistics_{name}"
4752 if "csv" in config[
"formats"]
and window
is not None:
4753 artifacts.append((
"csv", f
"{csv_base}_statistics_{name}.csv", window))
4754 if "csv" in config[
"formats"]
and config[
"homogeneous"]:
4755 artifacts.append((
"profile", f
"{csv_base}_profile_{name}", window))
4761 @brief Build the flat key=value mapping consumed by the C post-processor.
4762 @param[in] post_cfg Argument passed to `build_post_recipe_config()`.
4763 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
4764 @return Value returned by `build_post_recipe_config()`.
4772 eulerian_pipeline_parts = []
4773 dimensionalize = bool(post_cfg.get(
'global_operations', {}).get(
'dimensionalize',
False))
4778 c_config[
'dimensionalize'] =
'true'
4780 for task
in post_cfg.get(
'eulerian_pipeline', []):
4781 task_name = task.get(
'task')
4782 if task_name ==
'q_criterion':
4783 eulerian_pipeline_parts.append(
'ComputeQCriterion')
4784 elif task_name ==
'normalize_field':
4785 field = task.get(
'field',
'P')
4786 eulerian_pipeline_parts.append(f
'NormalizeRelativeField:{field}')
4787 ref_point = task.get(
'reference_point', [1, 1, 1])
4788 c_config[
'reference_ip'] = ref_point[0]
4789 c_config[
'reference_jp'] = ref_point[1]
4790 c_config[
'reference_kp'] = ref_point[2]
4791 elif task_name ==
'nodal_average':
4792 in_field = task.get(
'input_field')
4793 out_field = task.get(
'output_field')
4794 if in_field
and out_field:
4795 eulerian_pipeline_parts.append(f
'CellToNodeAverage:{in_field}>{out_field}')
4797 if eulerian_pipeline_parts:
4798 c_config[
'process_pipeline'] =
";".join(eulerian_pipeline_parts)
4800 lagrangian_pipeline_parts = []
4801 for task
in post_cfg.get(
'lagrangian_pipeline', []):
4802 task_name = task.get(
'task')
4803 if task_name ==
'specific_ke':
4804 in_field = task.get(
'input_field')
4805 out_field = task.get(
'output_field')
4806 if in_field
and out_field:
4807 lagrangian_pipeline_parts.append(f
'ComputeSpecificKE:{in_field}>{out_field}')
4808 if lagrangian_pipeline_parts:
4809 c_config[
'particle_pipeline'] =
";".join(lagrangian_pipeline_parts)
4812 statistics_output_prefix =
None
4813 stats_cfg = post_cfg.get(
'statistics_pipeline')
4814 if isinstance(stats_cfg, dict):
4815 statistics_output_prefix = stats_cfg.get(
'output_prefix')
4817 if statistics_pipeline_parts:
4818 c_config[
'statistics_pipeline'] =
";".join(statistics_pipeline_parts)
4820 elif statistics_output_prefix
is None:
4821 statistics_output_prefix = post_cfg.get(
'statistics_output_prefix')
4822 if statistics_output_prefix:
4823 c_config[
'statistics_output_prefix'] = statistics_output_prefix
4825 io = post_cfg.get(
'io', {})
4826 internal_paths = post_cfg.get(
'_picurv_paths', {})
or {}
4828 if field_statistics[
"windows"]
or field_statistics[
"ensembles"]:
4829 c_config[
'field_statistics_windows'] =
",".join(field_statistics[
"windows"])
4830 c_config[
'field_statistics_outputs'] =
",".join(field_statistics[
"outputs"])
4831 c_config[
'field_statistics_formats'] =
",".join(field_statistics[
"formats"])
4834 if field_statistics[
"source_step"]
is not None:
4835 c_config[
'field_statistics_source_step'] = field_statistics[
"source_step"]
4836 if internal_paths.get(
"field_statistics_prefix"):
4837 c_config[
'field_statistics_output_prefix'] = internal_paths[
"field_statistics_prefix"]
4838 if field_statistics[
"homogeneous"]:
4839 c_config[
'field_statistics_homogeneous'] =
",".join(field_statistics[
"homogeneous"])
4842 if field_statistics[
"ensembles"]:
4843 c_config[
'field_statistics_ensemble_count'] = len(field_statistics[
"ensembles"])
4844 for index, ensemble
in enumerate(field_statistics[
"ensembles"]):
4845 prefix = f
"field_statistics_ensemble_{index}"
4846 c_config[f
'{prefix}_name'] = ensemble[
"name"]
4847 c_config[f
'{prefix}_source'] = ensemble[
"source"]
4848 c_config[f
'{prefix}_field_count'] = len(ensemble[
"fields"])
4849 for field_index, field_entry
in enumerate(ensemble[
"fields"]):
4850 c_config[f
'{prefix}_field_{field_index}_name'] = field_entry[
"field"]
4851 c_config[f
'{prefix}_field_{field_index}_moments'] =
",".join(field_entry[
"moments"])
4852 c_config[f
'{prefix}_covariance_count'] = len(ensemble[
"covariances"])
4853 for pair_index, pair
in enumerate(ensemble[
"covariances"]):
4854 c_config[f
'{prefix}_covariance_{pair_index}'] = f
"{pair[0]},{pair[1]}"
4856 c_config[
'output_prefix'] = io.get(
'output_directory',
'viz') +
'/' + io.get(
'output_filename_prefix',
'Field')
4857 c_config[
'particle_output_prefix'] = io.get(
'output_directory',
'viz') +
'/' + io.get(
'particle_filename_prefix',
'Particle')
4858 c_config[
'output_particles'] = io.get(
'output_particles',
False)
4859 c_config[
'particle_output_freq'] = io.get(
'particle_subsampling_frequency', 1)
4860 c_config[
'output_fields_instantaneous'] =
",".join(io.get(
'eulerian_fields', []))
4861 c_config[
'particle_fields_instantaneous'] =
",".join(io.get(
'particle_fields', []))
4863 if isinstance(input_extensions, dict):
4864 for extension_name
in (
'eulerian',
'particle'):
4865 extension = input_extensions.get(extension_name)
4866 if extension
and str(extension).strip().lstrip(
'.').lower() !=
'dat':
4868 f
"post input extension '{extension_name}' must be 'dat'; "
4869 "committed checkpoint payload names are fixed."
4873 if source_directory
is not None:
4874 c_config[
'source_directory'] = source_directory
4881 if spectra[
"tasks"]:
4889 @brief Compute a stable human-readable identity for one post recipe.
4890 @param[in] post_cfg Parsed post configuration before runtime path injection.
4891 @return Filesystem-safe recipe identity.
4893 normalized = copy.deepcopy(post_cfg
or {})
4894 normalized.pop(
"_picurv_paths",
None)
4898 source = normalized.setdefault(
"source_data", {})
4899 if isinstance(source, dict):
4900 source.pop(
"directory",
None)
4901 io = normalized.get(
"io")
4903 if isinstance(io, dict):
4904 io.pop(
"output_directory",
None)
4909 io.pop(
"paraview_series",
None)
4910 raw_label = io.get(
"output_filename_prefix")
4911 if isinstance(raw_label, str)
and raw_label.strip():
4912 label = re.sub(
r"[^A-Za-z0-9_.-]+",
"-", raw_label.strip()).strip(
"-")
or "post"
4913 run_control = normalized.get(
"run_control")
4914 if isinstance(run_control, dict):
4915 for key
in POST_RUN_CONTROL_ALIASES[
"start_step"] + POST_RUN_CONTROL_ALIASES[
"end_step"]:
4916 run_control.pop(key,
None)
4919 normalized.pop(
"reducers",
None)
4920 digest = hashlib.sha256(
4921 json.dumps(normalized, sort_keys=
True, separators=(
",",
":")).encode(
"utf-8")
4923 return f
"{label}-{digest}"
4928 @brief Route every post artifact into its fixed analysis or visualization home.
4929 @param[in] post_cfg Parsed user recipe.
4930 @param[in] run_dir Run receiving derived artifacts.
4931 @return Runtime-only config copy and stable recipe id.
4934 runtime = copy.deepcopy(post_cfg)
4935 if not isinstance(runtime.get(
"source_data"), dict):
4936 runtime[
"source_data"] = {}
4937 runtime[
"source_data"][
"directory"] = os.path.join(
4938 os.path.abspath(run_dir), CANONICAL_RUN_PATHS[
"output"]
4940 io = runtime.setdefault(
"io", {})
4941 io[
"output_directory"] = os.path.join(CANONICAL_RUN_PATHS[
"visualization"], recipe_id)
4942 output_prefix = io.get(
"output_filename_prefix",
"Field")
4943 stats_cfg = runtime.get(
"statistics_pipeline")
4944 if isinstance(stats_cfg, dict):
4945 basename = stats_cfg.get(
"output_prefix",
"Stats")
4946 stats_cfg[
"output_prefix"] = os.path.join(
4947 CANONICAL_RUN_PATHS[
"statistics"], recipe_id, os.path.basename(str(basename))
4949 runtime[
"_picurv_paths"] = {
4950 "recipe_id": recipe_id,
4951 "recipe_root": os.path.join(
"config",
"post-recipes", recipe_id),
4952 "visualization": io[
"output_directory"],
4953 "statistics": os.path.join(CANONICAL_RUN_PATHS[
"statistics"], recipe_id),
4954 "field_statistics_prefix": os.path.join(
4955 CANONICAL_RUN_PATHS[
"statistics"], recipe_id, str(output_prefix)
4957 "spectra": os.path.join(CANONICAL_RUN_PATHS[
"spectra"], recipe_id),
4959 return runtime, recipe_id
4964 @brief Normalize post recipe settings into a stable signature mapping.
4965 @param[in] recipe_cfg Argument passed to `normalize_post_recipe_signature()`.
4966 @return Value returned by `normalize_post_recipe_signature()`.
4969 for key, value
in (recipe_cfg
or {}).items():
4970 if key
in POST_RECIPE_SIGNATURE_EXCLUDED_KEYS
or value
is None:
4972 if isinstance(value, bool):
4973 text =
'true' if value
else 'false'
4975 text = str(value).strip()
4976 if text.lower()
in {
'true',
'false'}:
4979 signature[str(key)] = text
4985 @brief Return normalized recipe signature plus SHA-256 fingerprint.
4986 @param[in] recipe_cfg Argument passed to `compute_post_recipe_fingerprint()`.
4987 @return Value returned by `compute_post_recipe_fingerprint()`.
4990 payload = json.dumps(signature, sort_keys=
True, separators=(
',',
':')).encode(
'utf-8')
4991 return signature, hashlib.sha256(payload).hexdigest()
4996 @brief Return the JSON resume metadata path for a run directory.
4997 @param[in] run_dir Argument passed to `get_post_resume_state_path()`.
4998 @param[in] post_cfg Optional versioned post recipe configuration.
4999 @return Value returned by `get_post_resume_state_path()`.
5001 if post_cfg
is None:
5002 return os.path.join(run_dir,
'config', POST_RESUME_STATE_FILENAME)
5008 @brief Return the versioned run-local control directory for one post recipe.
5009 @param[in] run_dir Owning run root.
5010 @param[in] post_cfg Runtime post config with canonical paths, or a user recipe.
5011 @return Absolute recipe control directory.
5013 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
5015 return os.path.join(os.path.abspath(run_dir),
"config",
"post-recipes", recipe_id)
5020 @brief Return lock-wrapper related paths for a run directory.
5021 @param[in] run_dir Argument passed to `get_post_lock_paths()`.
5022 @param[in] recipe_id Optional stable recipe identity used to scope the lock.
5023 @return Value returned by `get_post_lock_paths()`.
5025 scheduler_dir = os.path.join(run_dir,
'scheduler')
5026 suffix = f
".{recipe_id}" if recipe_id
else ""
5028 'lock_file': os.path.join(scheduler_dir, f
"post{suffix}.lock"),
5029 'metadata_file': os.path.join(scheduler_dir, f
"post{suffix}.lock.json"),
5030 'wrapper_path': os.path.join(scheduler_dir, POST_LOCK_WRAPPER_FILENAME),
5036 @brief Resolve the absolute post output directory for the current recipe.
5037 @param[in] run_dir Argument passed to `_post_output_directory_abs()`.
5038 @param[in] post_cfg Argument passed to `_post_output_directory_abs()`.
5039 @return Value returned by `_post_output_directory_abs()`.
5041 io_cfg = post_cfg.get(
'io', {})
or {}
5042 internal = (post_cfg
or {}).get(
"_picurv_paths", {})
or {}
5043 relative = internal.get(
"visualization")
or io_cfg.get(
'output_directory')
5046 return os.path.abspath(os.path.join(run_dir, relative))
5051 @brief Return whether the current post recipe expects Eulerian VTK output artifacts.
5052 @param[in] post_cfg Argument passed to `_post_requests_eulerian_output()`.
5053 @return Value returned by `_post_requests_eulerian_output()`.
5055 io_cfg = post_cfg.get(
'io', {})
or {}
5056 return bool(io_cfg.get(
'eulerian_fields'))
5061 @brief Return whether the current post recipe expects particle VTP output artifacts.
5062 @param[in] post_cfg Argument passed to `_post_requests_particle_output()`.
5063 @return Value returned by `_post_requests_particle_output()`.
5065 io_cfg = post_cfg.get(
'io', {})
or {}
5066 return bool(io_cfg.get(
'output_particles'))
and bool(io_cfg.get(
'particle_fields'))
5069POST_PARAVIEW_SERIES_SCOPES = {
"run",
"lineage"}
5074 @brief Normalize the optional ParaView collection policy in a post recipe.
5075 @param[in] post_cfg Parsed post-processing configuration.
5076 @return Mapping with enabled and scope keys.
5078 raw = ((post_cfg
or {}).get(
"io")
or {}).get(
"paraview_series")
5080 return {
"enabled":
False,
"scope":
"run"}
5081 if isinstance(raw, bool):
5082 return {
"enabled": raw,
"scope":
"lineage" if raw
else "run"}
5083 if not isinstance(raw, dict):
5084 raise ValueError(
"'io.paraview_series' must be a boolean or mapping.")
5085 enabled = raw.get(
"enabled",
True)
5086 scope = str(raw.get(
"scope",
"lineage")).strip().lower()
5087 if not isinstance(enabled, bool):
5088 raise ValueError(
"'io.paraview_series.enabled' must be a boolean.")
5089 if scope
not in POST_PARAVIEW_SERIES_SCOPES:
5090 raise ValueError(
"'io.paraview_series.scope' must be 'run' or 'lineage'.")
5091 return {
"enabled": enabled,
"scope": scope}
5096 @brief Resolve and identity-check one recorded lineage parent.
5097 @param[in] child_run_dir Child run directory used to resolve workspace-relative paths.
5098 @param[in] lineage Recorded branch-lineage mapping.
5099 @return Absolute path to the identity-matched parent run.
5101 recorded = lineage.get(
"parent_path")
5102 parent_id = lineage.get(
"parent_run_id")
5103 if not recorded
or not parent_id:
5104 raise ValueError(f
"Run lineage is missing parent_path or parent_run_id: {child_run_dir}")
5105 if os.path.isabs(str(recorded)):
5106 candidates = [os.path.abspath(str(recorded))]
5109 candidates = ([os.path.abspath(os.path.join(workspace_root, str(recorded)))]
5110 if workspace_root
else [])
5113 runs_root = os.path.join(workspace_root,
"runs")
5114 if os.path.isdir(runs_root):
5116 os.path.join(runs_root, name)
for name
in os.listdir(runs_root)
5117 if os.path.isdir(os.path.join(runs_root, name))
5120 for candidate
in candidates:
5121 candidate = os.path.abspath(candidate)
5122 if candidate
in seen
or not os.path.isdir(candidate):
5126 identity = read_artifact_identity(candidate)
5127 except (OSError, ValueError, KeyError):
5129 if identity.get(
"run_id") == parent_id:
5132 f
"Lineage parent '{parent_id}' for run '{os.path.basename(child_run_dir)}' "
5133 f
"could not be resolved from recorded path '{recorded}'."
5139 @brief Return root-to-leaf run segments, for a ParaView collection or a reducer's metrics.
5140 @param[in] run_dir Leaf run whose lineage is resolved.
5141 @param[in] scope Either run-local or full-lineage collection scope.
5142 @return Ordered run-segment mappings from root to leaf.
5143 @details Each entry carries the run path, manifest, and the checkpoint at which
5144 that run branched from its parent. Cycles and unresolved parents fail.
5146 current = os.path.abspath(run_dir)
5151 run_id = manifest.get(
"run_id")
or os.path.basename(current)
5152 if run_id
in seen_ids:
5153 chain =
" -> ".join([entry[
"run_id"]
for entry
in reversed(reverse)] + [run_id])
5154 raise ValueError(f
"Cycle detected while resolving visualization lineage: {chain}")
5155 seen_ids.add(run_id)
5156 lineage = manifest.get(
"lineage")
or {
"relationship":
"root"}
5160 "manifest": manifest,
5162 "fork_step": (int(lineage[
"checkpoint_step"])
5163 if lineage.get(
"relationship") ==
"branch" else None),
5165 if scope ==
"run" or lineage.get(
"relationship") !=
"branch":
5168 return list(reversed(reverse))
5173 @brief Read step-to-time fallbacks from an existing PVD collection.
5174 @param[in] pvd_path Existing collection path.
5175 @return Mapping from output step to physical time.
5177 if not os.path.isfile(pvd_path):
5180 root = ElementTree.parse(pvd_path).getroot()
5181 except (ElementTree.ParseError, OSError):
5184 for dataset
in root.findall(
".//DataSet"):
5185 filename = os.path.basename(dataset.get(
"file",
""))
5186 match = re.search(
r"_(\d+)\.(?:vts|vtp)$", filename)
5189 result[int(match.group(1))] = float(dataset.get(
"timestep"))
5190 except (TypeError, ValueError):
5197 @brief Read the authoritative time of one visualization frame.
5198 @details Checkpoints record solver time. A dimensionalized recipe writes physical
5199 frames, so its collection reports physical time: the checkpoint time
5200 multiplied by the owning run's reference time.
5201 @param[in] run_dir Run owning the frame.
5202 @param[in] step Frame checkpoint step.
5203 @param[in] fallback Existing collection times, already in the collection's units,
5204 used after checkpoint pruning.
5205 @param[in] time_scale Factor from solver time to the collection's time unit.
5206 @return Frame time in the collection's unit system.
5208 metadata_path = os.path.join(
5209 run_dir, CANONICAL_RUN_PATHS[
"checkpoints"],
5210 f
"step_{step:0{CHECKPOINT_STEP_WIDTH}d}",
"checkpoint.meta",
5212 commit_path = os.path.join(os.path.dirname(metadata_path),
"COMMITTED")
5213 if os.path.isfile(metadata_path)
and os.path.isfile(commit_path):
5216 recorded_step = int(options[
"checkpoint_step"])
5217 physical_time = float(options[
"checkpoint_time"])
5218 except (KeyError, TypeError, ValueError)
as exc:
5219 raise ValueError(f
"Checkpoint time metadata is malformed: {metadata_path}")
from exc
5220 if recorded_step != step
or not math.isfinite(physical_time):
5221 raise ValueError(f
"Checkpoint time metadata does not match step {step}: {metadata_path}")
5222 return physical_time * time_scale
5223 if step
in fallback:
5224 return float(fallback[step])
5226 f
"Visualization frame at step {step} has no locally available checkpoint time in "
5227 f
"'{run_dir}'. Restore its checkpoints or retain an existing PVD carrying the time."
5233 @brief Atomically write one VTK Collection file.
5234 @param[in] path Destination PVD path.
5235 @param[in] entries Ordered frame mappings.
5238 root = ElementTree.Element(
"VTKFile", {
5239 "type":
"Collection",
"version":
"0.1",
"byte_order":
"LittleEndian",
5241 collection = ElementTree.SubElement(root,
"Collection")
5242 base = os.path.dirname(os.path.abspath(path))
5243 for entry
in entries:
5244 ElementTree.SubElement(collection,
"DataSet", {
5245 "timestep": format(float(entry[
"time"]),
".17g"),
5246 "group":
"",
"part":
"0",
5247 "file": os.path.relpath(entry[
"path"], base).replace(os.sep,
"/"),
5249 content =
'<?xml version="1.0"?>\n' + ElementTree.tostring(root, encoding=
"unicode") +
"\n"
5250 os.makedirs(base, exist_ok=
True)
5251 temporary = f
"{path}.tmp.{os.getpid()}"
5253 with open(temporary,
"w", encoding=
"utf-8")
as stream:
5254 stream.write(content)
5256 os.fsync(stream.fileno())
5257 os.replace(temporary, path)
5259 if os.path.exists(temporary):
5260 os.unlink(temporary)
5265 @brief Return the first segment belonging to one continuous artifact family.
5266 @param[in] segments Root-to-leaf lineage segments.
5267 @param[in] family Artifact family being indexed.
5268 @return Index of the first segment after the last relevant reset.
5271 for index, segment
in enumerate(segments[1:], start=1):
5272 if family ==
"particle":
5273 case_path = os.path.join(segment[
"run_dir"],
"config",
"case.yml")
5274 case_cfg =
read_yaml_file(case_path)
if os.path.isfile(case_path)
else {}
5275 particles = (((case_cfg.get(
"models")
or {}).get(
"physics")
or {}).get(
"particles")
or {})
5276 if str(particles.get(
"restart_mode",
"init")).strip().lower() !=
"load":
5278 elif family ==
"statistics":
5279 if str(segment[
"lineage"].get(
"statistics_state",
"reset")).lower() !=
"carry":
5286 @brief Build physical-time ParaView collections from current or lineage VTK output.
5287 @param[in] run_dir Leaf run receiving the collection.
5288 @param[in] post_cfg Parsed post-processing recipe.
5289 @return Absolute PVD paths written. Disabled recipes return an empty list.
5292 if not policy[
"enabled"]:
5294 run_dir = os.path.abspath(run_dir)
5295 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
5297 recipe_id = post_cfg[
"_picurv_paths"][
"recipe_id"]
5303 io_cfg = post_cfg.get(
"io", {})
or {}
5306 families.append((
"field", io_cfg.get(
"output_filename_prefix",
"Field"),
"vts"))
5308 families.append((
"particle", io_cfg.get(
"particle_filename_prefix",
"Particle"),
"vtp"))
5311 families.append((
"statistics", os.path.basename(prefix_path),
"vts"))
5315 dimensionalize = bool((post_cfg.get(
"global_operations")
or {}).get(
"dimensionalize",
False))
5317 def segment_time_scale(segment: dict) -> float:
5319 @brief Return the factor from one lineage run's solver time to the collection's time.
5320 @param[in] segment Lineage segment.
5321 @return Reference time of the run when dimensionalizing, otherwise 1.
5323 if not dimensionalize:
5325 case_path = os.path.join(segment[
"run_dir"],
"config",
"case.yml")
5329 for family, prefix, extension
in families:
5332 for index, segment
in enumerate(active_segments):
5333 lower = requested_start
5334 if segment[
"fork_step"]
is not None:
5335 lower = max(lower, int(segment[
"fork_step"]))
5336 upper = requested_end
5337 if index + 1 < len(active_segments):
5338 upper = min(upper, int(active_segments[index + 1][
"fork_step"]) - 1)
5341 viz_dir = os.path.join(
5342 segment[
"run_dir"], CANONICAL_RUN_PATHS[
"visualization"], recipe_id
5344 pvd_path = os.path.join(viz_dir, f
"{prefix}.pvd")
5346 pattern = re.compile(rf
"^{re.escape(prefix)}_(\d+)\.{re.escape(extension)}$")
5348 if os.path.isdir(viz_dir):
5349 for name
in os.listdir(viz_dir):
5350 match = pattern.match(name)
5351 if match
and lower <= int(match.group(1)) <= upper:
5352 found.append((int(match.group(1)), os.path.join(viz_dir, name)))
5353 if not found
and index + 1 < len(active_segments):
5355 f
"Lineage run '{segment['run_id']}' has no {prefix} visualization "
5356 f
"frames for its required segment {lower}..{upper} and recipe '{recipe_id}'."
5358 for step, frame_path
in sorted(found):
5362 segment_time_scale(segment)),
5363 "path": os.path.abspath(frame_path),
5364 "run_id": segment[
"run_id"],
5368 for previous, current
in zip(entries, entries[1:]):
5369 if current[
"time"] <= previous[
"time"]:
5371 f
"ParaView series time is not strictly increasing: {previous['run_id']} "
5372 f
"step {previous['step']} has {previous['time']:.17g}, followed by "
5373 f
"{current['run_id']} step {current['step']} at {current['time']:.17g}."
5376 output_path = os.path.join(output_dir, f
"{prefix}.pvd")
5379 f
"[SUCCESS] Wrote ParaView {family} series: {output_path} "
5380 f
"({len(entries)} frame(s), time {entries[0]['time']:.17g}..{entries[-1]['time']:.17g})"
5382 written.append(output_path)
5388 @brief Return the importable serial finalizer used by generated jobs.
5389 @return Python source suitable for a serial interpreter invocation.
5391 package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5394 f
"sys.path.insert(0,{package_root!r});"
5395 "from picurv_cli.core import read_yaml_file,apply_canonical_post_paths,finalize_post_outputs;"
5396 "run=os.path.abspath(sys.argv[1]);cfg=read_yaml_file(sys.argv[2]);"
5397 "cfg,_=apply_canonical_post_paths(cfg,run);finalize_post_outputs(run,cfg,(sys.argv[3] if len(sys.argv)>3 else '').split(','))"
5402 post_stages=POST_STAGE_NAMES) -> list:
5404 @brief Build the serial post-success command for staged execution.
5405 @details It runs after the field post-processor succeeds, in the conductor or in the
5406 submitted job, so whatever reads the field stage's output belongs here: the
5407 ParaView collections and the reducers.
5408 @param[in] run_dir Run receiving the collection.
5409 @param[in] archived_post_path Archived post recipe read by the finalizer.
5410 @param[in] post_cfg Parsed post-processing recipe.
5411 @param[in] post_stages Selected post stages; reducers run only when selected.
5412 @return Command tokens, or an empty list when there is nothing to finalize.
5419 os.path.abspath(run_dir), os.path.abspath(archived_post_path),
5420 "reducers" if reducers
else "-",
5426 @brief Run every serial step that reads a successful field stage's output.
5427 @param[in] run_dir Run directory.
5428 @param[in] post_cfg Recipe with canonical paths applied.
5429 @param[in] stages Stage names the finalizer covers beyond the collections.
5433 if "reducers" in stages:
5439 @brief Return whether the current post recipe expects statistics CSV artifacts.
5440 @param[in] post_cfg Argument passed to `_post_requests_statistics()`.
5441 @return Value returned by `_post_requests_statistics()`.
5448 @brief Return whether the current post recipe requires particle source files to be present.
5449 @param[in] post_cfg Argument passed to `_post_needs_particle_source()`.
5450 @return Value returned by `_post_needs_particle_source()`.
5452 io_cfg = post_cfg.get(
'io', {})
or {}
5453 return bool(io_cfg.get(
'output_particles'))
or bool(post_cfg.get(
'lagrangian_pipeline'))
or _post_requests_statistics(post_cfg)
5458 @brief Yield configured post-processing steps inclusively.
5459 @param[in] start_step Argument passed to `_iter_post_steps()`.
5460 @param[in] end_step Argument passed to `_iter_post_steps()`.
5461 @param[in] step_interval Argument passed to `_iter_post_steps()`.
5463 if step_interval <= 0
or end_step < start_step:
5466 while step <= end_step:
5468 step += step_interval
5473 @brief Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
5474 @param[in] post_cfg Argument passed to `resolve_post_requested_window()`.
5475 @param[in] case_cfg Optional case configuration for end-step expansion.
5476 @return Value returned by `resolve_post_requested_window()`.
5481 if step_interval <= 0:
5482 raise ValueError(
'run_control.step_interval must be positive.')
5483 if end_step < 0
and case_cfg:
5484 case_run = case_cfg.get(
'run_control', {})
or {}
5485 case_start = int(case_run.get(
'start_step', 0)
or 0)
5486 case_total = int(case_run.get(
'total_steps', 0)
or 0)
5487 end_step = case_start + case_total
5488 return start_step, end_step, step_interval
5492 step_interval: int) -> int:
5494 @brief Intersect a recipe's logical cadence with this run's branch.
5495 @details A branch run holds checkpoints only from the step it forked at, so steps
5496 before it belong to its ancestors and never appear here; the run's window
5497 starts at its first cadence step at or after the fork.
5498 @param[in] run_dir Run whose branch ownership is inspected.
5499 @param[in] post_cfg Parsed post-processing recipe.
5500 @param[in] requested_start Logical recipe start step.
5501 @param[in] step_interval Logical recipe cadence.
5502 @return First cadence step owned by the run.
5506 lineage = manifest.get(
"lineage")
or {}
5507 if lineage.get(
"relationship") !=
"branch":
5508 return requested_start
5509 fork_step = int(lineage[
"checkpoint_step"])
5510 if fork_step <= requested_start:
5511 return requested_start
5512 intervals = (fork_step - requested_start + step_interval - 1) // step_interval
5513 return requested_start + intervals * step_interval
5518 @brief Return a copy of post_cfg with resolved source dir and optional effective bounds.
5519 @param[in] post_cfg Argument passed to `prepare_effective_post_config()`.
5520 @param[in] resolved_source_dir Argument passed to `prepare_effective_post_config()`.
5521 @param[in] start_step Argument passed to `prepare_effective_post_config()`.
5522 @param[in] end_step Argument passed to `prepare_effective_post_config()`.
5523 @return Value returned by `prepare_effective_post_config()`.
5525 effective_cfg = copy.deepcopy(post_cfg)
5526 if not isinstance(effective_cfg.get(
'source_data'), dict):
5527 effective_cfg[
'source_data'] = {}
5528 effective_cfg[
'source_data'][
'directory'] = resolved_source_dir
5529 rc = effective_cfg.setdefault(
'run_control', {})
5530 if start_step
is not None:
5531 rc[
'start_step'] = int(start_step)
5532 if end_step
is not None:
5533 rc[
'end_step'] = int(end_step)
5534 return effective_cfg
5539 @brief Collect the VTK files named with a prefix, step suffix, and extension.
5540 @param[in] prefix_path Output path prefix before the numeric step suffix.
5541 @param[in] extension VTK file extension to match without its leading dot.
5542 @return Mapping of each step number to its file path in the prefix directory.
5544 directory = os.path.dirname(prefix_path)
5545 if not os.path.isdir(directory):
5547 basename = os.path.basename(prefix_path)
5548 pattern = re.compile(rf
'^{re.escape(basename)}_(\d+)\.{re.escape(extension)}$')
5550 for name
in os.listdir(directory):
5551 match = pattern.match(name)
5553 steps[int(match.group(1))] = os.path.join(directory, name)
5559 @brief Scan step ids from the first CSV column of a statistics artifact.
5560 @param[in] csv_path Argument passed to `_scan_post_statistics_csv_steps()`.
5561 @return Value returned by `_scan_post_statistics_csv_steps()`.
5563 if not os.path.isfile(csv_path):
5566 with open(csv_path,
'r', encoding=
'utf-8', errors=
'replace', newline=
'')
as f:
5567 reader = csv.reader(f)
5571 head = str(row[0]).strip().lower()
5572 if head
in {
'step',
'timestep',
'time_step'}:
5575 if step_val
is not None:
5582 @brief Collect the output files each output family of the current recipe holds per step.
5583 @param[in] run_dir Argument passed to `collect_post_completion_families()`.
5584 @param[in] post_cfg Argument passed to `collect_post_completion_families()`.
5585 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
5586 @return (step-to-path mapping, window, per_step) per family; window names the
5587 field-statistics window a family belongs to, or is None for a family every
5588 processed step must produce; per_step is False for a CSV shared by all steps.
5590 io_cfg = post_cfg.get(
'io', {})
or {}
5594 def csv_family(path):
5596 @brief Map every step a shared CSV holds to that CSV.
5597 @param[in] path CSV artifact path.
5598 @return Mapping of step to the CSV path.
5603 prefix = os.path.join(output_dir_abs, io_cfg.get(
'output_filename_prefix',
'Field'))
5607 prefix = os.path.join(output_dir_abs, io_cfg.get(
'particle_filename_prefix',
'Particle'))
5611 families.append((csv_family(stats_path),
None,
False))
5618 elif kind ==
'profile':
5621 families.append((csv_family(path), window,
False))
5628 @brief Read each field-statistics window's sample count from one committed checkpoint.
5629 @param[in] run_dir Run directory whose output holds the checkpoint.
5630 @param[in] step Checkpoint step.
5631 @return Mapping of window name to sample count, or None when the metadata is absent
5632 (never written, pruned, or offloaded).
5634 metadata = os.path.join(
5638 if not os.path.isfile(metadata):
5642 for index
in range(int(options.get(
"checkpoint_statistics_window_count", 0)
or 0)):
5643 name = options.get(f
"checkpoint_statistics_window_{index}_name")
5644 if name
is not None:
5645 samples[name] = int(options.get(f
"checkpoint_statistics_window_{index}_sample_count", 0)
or 0)
5651 @brief Return a lookup of the output files one step of the current recipe holds.
5652 @details A step is complete when every family it must produce has its output. The
5653 post-processor writes a statistics window's output at a step only if the
5654 window had accumulated a sample in the bundle it reads, the pinned
5655 source_step or the step itself, so a window that had not opened yet is not
5657 @param[in] run_dir Run whose output is inspected.
5658 @param[in] post_cfg Runtime recipe with canonical paths.
5659 @param[in] monitor_cfg Parsed monitor configuration.
5660 @return Function mapping a step to (per-step files, shared files), or None when the
5661 step lacks an output it must have.
5667 def expected(window, step):
5669 @brief Whether a family's output must exist at this step.
5670 @param[in] window Field-statistics window the family belongs to, or None.
5671 @param[in] step Processed step.
5672 @return True when the family is always required or its window had samples.
5676 source = int(pinned)
if pinned
is not None else step
5677 if source
not in sample_cache:
5679 samples = sample_cache[source]
5681 return samples
is None or samples.get(window, 0) > 0
5685 @brief Collect one step's output files.
5686 @param[in] step Processed step.
5687 @return (per-step files, shared files), or None when an expected output is missing.
5689 own, shared = [], []
5690 for mapping, window, per_step
in families:
5692 (own
if per_step
else shared).append(mapping[step])
5693 elif expected(window, step):
5702 @brief Return the complete source step nearest to a target step.
5703 @param[in] steps Candidate step numbers.
5704 @param[in] target Target step number.
5705 @return Nearest candidate, or None when no candidates exist.
5709 return min(steps, key=
lambda step: (abs(step - target), step))
5714 @brief Format an optional step number for user-facing diagnostics.
5715 @param[in] step Step number or None.
5716 @return Printable step text.
5718 return 'none' if step
is None else str(step)
5723 @brief Scan source artifacts and return steps with every file required by the recipe.
5724 @param[in] source_dir Source output root directory.
5725 @param[in] monitor_cfg Parsed monitor configuration.
5726 @param[in] post_cfg Parsed post-processing configuration.
5727 @return Tuple of complete source steps and source path metadata.
5732 source_dir, require_particles=require_particles
5734 return complete_steps, {
5735 'source_dir': os.path.abspath(source_dir),
5736 'require_particles': require_particles,
5742 @brief Build required source file paths for a single post-processing step.
5743 @param[in] step Requested step number.
5744 @param[in] source_scan Metadata returned by `_scan_complete_source_steps()`.
5745 @param[in] post_cfg Parsed post-processing configuration.
5746 @return Required source artifact paths.
5750 paths = [os.path.join(bundle,
'checkpoint.meta'), os.path.join(bundle,
'COMMITTED')]
5751 if source_scan.get(
'require_particles'):
5752 paths.append(os.path.join(bundle,
'particles',
'position.dat'))
5758 @brief Detect the highest contiguous fully available source step for live post-processing.
5759 @param[in] source_dir Argument passed to `detect_post_source_frontier()`.
5760 @param[in] monitor_cfg Argument passed to `detect_post_source_frontier()`.
5761 @param[in] post_cfg Argument passed to `detect_post_source_frontier()`.
5762 @param[in] start_step Argument passed to `detect_post_source_frontier()`.
5763 @param[in] end_step Argument passed to `detect_post_source_frontier()`.
5764 @param[in] step_interval Argument passed to `detect_post_source_frontier()`.
5765 @return Value returned by `detect_post_source_frontier()`.
5768 'first_requested_step': start_step,
5769 'first_incomplete_step':
None,
5770 'missing_files_for_first_incomplete_step': [],
5771 'closest_complete_step_to_start':
None,
5772 'closest_complete_step_to_end':
None,
5774 if step_interval <= 0
or end_step < start_step
or not os.path.isdir(source_dir):
5776 'frontier_step':
None,
5777 'diagnostic': diagnostic,
5781 diagnostic[
'closest_complete_step_to_start'] =
_nearest_step(complete_steps, start_step)
5782 diagnostic[
'closest_complete_step_to_end'] =
_nearest_step(complete_steps, end_step)
5789 require_particles=source_scan.get(
'require_particles',
False),
5791 except CheckpointIndexWidthError:
5795 diagnostic[
'first_incomplete_step'] = step
5796 diagnostic[
'missing_files_for_first_incomplete_step'] = [
5797 os.path.relpath(path, source_dir)
for path
in expected_paths
if not os.path.isfile(path)
5799 if not diagnostic[
'missing_files_for_first_incomplete_step']:
5800 diagnostic[
'missing_files_for_first_incomplete_step'] = [
5802 +
' (invalid bundle)'
5807 'frontier_step': frontier,
5808 'diagnostic': diagnostic,
5814 @brief Read the per-step output records a recipe's state holds.
5815 @param[in] state_path The recipe's state.json.
5816 @return (field-stage records, spectra records), each keyed by the step as text; both
5817 empty for a state written before records existed.
5820 if not isinstance(state, dict)
or state.get(
"schema_version") != POST_RESUME_SCHEMA_VERSION:
5822 return dict(state.get(
"steps")
or {}), dict(state.get(
"spectra")
or {})
5827 @brief Write a recipe's state: the window last planned and its per-step output records.
5828 @param[in] run_dir Run the recipe belongs to.
5829 @param[in] plan Plan from build_post_execution_plan().
5830 @param[in] steps Field-stage records to merge in, keyed by step, or None.
5831 @param[in] spectra Spectra records to merge in, keyed by step, or None.
5832 @return Path of the written state file.
5836 field_records.update({str(step): record
for step, record
in (steps
or {}).items()})
5837 spectra_records.update({str(step): record
for step, record
in (spectra
or {}).items()})
5839 'schema_version': POST_RESUME_SCHEMA_VERSION,
5840 'run_id': plan.get(
'run_id'),
5841 'recipe_fingerprint': plan.get(
'recipe_fingerprint'),
5842 'recipe_signature': plan.get(
'recipe_signature'),
5843 'requested_start_step': plan.get(
'requested_start_step'),
5844 'requested_end_step': plan.get(
'requested_end_step'),
5845 'step_interval': plan.get(
'step_interval'),
5846 'source_directory': plan.get(
'source_data_directory'),
5847 'updated_at': datetime.now().isoformat(),
5848 'steps': dict(sorted(field_records.items(), key=
lambda item: int(item[0]))),
5849 'spectra': dict(sorted(spectra_records.items(), key=
lambda item: int(item[0]))),
5856 @brief Identify one committed checkpoint by its commit marker.
5857 @param[in] source_dir Output root holding the checkpoints.
5858 @param[in] step Checkpoint step.
5859 @return (marker digest, marker modification time), or None when the checkpoint is not
5860 local (never written, archived, or pruned).
5864 with open(marker,
"r", encoding=
"ascii")
as stream:
5865 return stream.read().strip(), os.stat(marker).st_mtime
5872 @brief Decide whether one step's existing output is still valid.
5873 @details Output stays valid while it was made by the same recipe from the checkpoint
5874 now on disk. The build that made it does not invalidate it: a rebuild changes
5875 every binary's hash, and redoing a whole window after each one is what
5876 `--recompute` is for. Output without a record (written before records
5877 existed, or by a job killed before it could record) is accepted when it is
5878 newer than its checkpoint, or when the checkpoint is no longer local.
5879 @param[in] record The step's record, or None.
5880 @param[in] files Files the step's output consists of, or None when any is missing.
5881 @param[in] commit Checkpoint identity from _checkpoint_commit(), or None.
5882 @param[in] recipe Current recipe fingerprint.
5883 @param[in] recompute Whether the invocation regenerates every step.
5884 @return None when the output is valid, else the reason it must be (re)made:
5885 `missing`, `recompute`, `recipe`, or `checkpoint`.
5887 if files
is None or (
not files
and record
is None):
5892 if commit
is None or min(os.path.getmtime(path)
for path
in files) >= commit[1]:
5895 if record.get(
"recipe") != recipe:
5897 if commit
is not None and record.get(
"checkpoint")
not in (
None, commit[0]):
5904 @brief Warn when another recipe in this run already produced outputs this one repeats.
5905 @details A recipe writes one file per step holding all its fields, so a recipe that
5906 repeats another's fields recomputes them rather than reusing them. The
5907 warning names the shared fields and statistics windows and the overlapping
5908 steps, so the new outputs can go into a recipe of their own instead.
5909 @param[in] run_dir Run being post-processed.
5910 @param[in] post_cfg Runtime recipe with canonical paths.
5911 @param[in] plan Plan from build_post_execution_plan().
5912 @param[in] post_stages Selected post stages.
5915 if 'fields' not in post_stages:
5917 current_id = ((post_cfg.get(
"_picurv_paths")
or {}).get(
"recipe_id")
5919 steps = set(plan[
'steps_to_process'])
5920 fields = set(((post_cfg.get(
'io')
or {}).get(
'eulerian_fields'))
or [])
5922 root = os.path.join(os.path.abspath(run_dir),
"config",
"post-recipes")
5923 if not steps
or not (fields
or windows)
or not os.path.isdir(root):
5925 for other_id
in sorted(os.listdir(root)):
5926 archived = os.path.join(root, other_id,
"post.yml")
5927 if other_id == current_id
or not os.path.isfile(archived):
5933 if resolved_id != other_id:
5935 labels, done = [], set()
5936 shared_fields = fields & set(((other_cfg.get(
'io')
or {}).get(
'eulerian_fields'))
or [])
5939 (other_cfg.get(
'io')
or {}).get(
'output_filename_prefix',
'Field'))
5942 labels.append(f
"fields {', '.join(sorted(shared_fields))}")
5946 if kind ==
'vtk' and window
in windows:
5949 shared_windows.append(window)
5952 labels.append(f
"statistics windows {', '.join(shared_windows)}")
5954 print(f
"[WARNING] Recipe {other_id} already produced {'; '.join(labels)} for "
5955 f
"{len(done)} of these steps ({min(done)}..{max(done)}); this recipe computes "
5956 "them again. If only some outputs are new, a recipe holding just those avoids "
5957 "the repeat.", file=sys.stderr)
5962 @brief Return the Python wrapper that runs one post job under an exclusive lock.
5963 @details Given `--post-recipe`, the wrapper plans the job's steps as it starts
5964 (prepare_post_job()), skips the postprocessor when none need work, and
5965 records the steps produced when the postprocessor exits, even on failure
5966 (finish_post_job()). It imports the conductor from this installation, so it
5967 must run under the conductor's interpreter.
5968 @return Value returned by `_build_post_lock_wrapper_source()`.
5970 package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5971 return """#!/usr/bin/env python3
5981PICURV_PACKAGE_ROOT = """ + repr(package_root) +
"""
5985 parser = argparse.ArgumentParser(description='PICurv post-stage lock wrapper')
5986 parser.add_argument('--lock-file', required=True)
5987 parser.add_argument('--metadata-file', required=True)
5988 parser.add_argument('--run-dir', required=True)
5989 parser.add_argument('--recipe-fingerprint', default='')
5990 parser.add_argument('--post-recipe')
5991 parser.add_argument('--postprocessor')
5992 parser.add_argument('--recompute', action='store_true')
5993 parser.add_argument('--success-command-json')
5994 parser.add_argument('command', nargs=argparse.REMAINDER)
5995 args = parser.parse_args()
5997 command = list(args.command or [])
5998 if not command or command[0] != '--':
5999 parser.error("expected '-- <command ...>' after wrapper arguments")
6000 command = command[1:]
6002 os.makedirs(os.path.dirname(args.lock_file), exist_ok=True)
6003 fd = os.open(args.lock_file, os.O_RDWR | os.O_CREAT, 0o644)
6005 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
6006 except BlockingIOError:
6009 with open(args.metadata_file, 'r', encoding='utf-8') as handle:
6010 owner = json.load(handle)
6015 f"[FATAL] Post stage already active for {args.run_dir} "
6016 f"(pid={owner.get('pid')}, host={owner.get('host')}, started_at={owner.get('started_at')}).",
6020 print(f"[FATAL] Post stage already active for {args.run_dir}.", file=sys.stderr)
6025 'host': socket.gethostname(),
6026 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
6027 'run_dir': args.run_dir,
6028 'recipe_fingerprint': args.recipe_fingerprint,
6031 with open(args.metadata_file, 'w', encoding='utf-8') as handle:
6032 json.dump(metadata, handle, indent=2, sort_keys=True)
6037 if args.post_recipe:
6038 sys.path.insert(0, PICURV_PACKAGE_ROOT)
6039 from picurv_cli.core import finish_post_job, prepare_post_job
6040 planned = prepare_post_job(args.run_dir, args.post_recipe, args.postprocessor,
6041 recompute=args.recompute)
6046 result = subprocess.run(command)
6048 if args.post_recipe:
6049 finish_post_job(args.run_dir, args.post_recipe,
6050 exited_cleanly=result is not None and result.returncode == 0)
6052 if result.returncode != 0:
6053 return int(result.returncode)
6055 print('[INFO] Every requested step with a committed checkpoint is up to date; '
6056 'the postprocessor is not launched.')
6057 if args.success_command_json:
6058 success_command = json.loads(args.success_command_json)
6059 if not isinstance(success_command, list) or not success_command:
6060 print('[FATAL] Invalid post success command.', file=sys.stderr)
6062 result = subprocess.run([str(token) for token in success_command])
6063 return int(result.returncode)
6067 os.remove(args.metadata_file)
6068 except FileNotFoundError:
6073if __name__ == '__main__':
6074 raise SystemExit(main())
6080 @brief Ensure the lock wrapper exists for a run directory and return its path.
6081 @param[in] run_dir Argument passed to `ensure_post_lock_wrapper()`.
6082 @return Value returned by `ensure_post_lock_wrapper()`.
6085 wrapper_path = paths[
'wrapper_path']
6087 existing_content =
None
6088 os.makedirs(os.path.dirname(wrapper_path), exist_ok=
True)
6089 if os.path.isfile(wrapper_path):
6090 with open(wrapper_path,
'r', encoding=
'utf-8', errors=
'replace')
as f:
6091 existing_content = f.read()
6092 if existing_content != content:
6093 with open(wrapper_path,
'w', encoding=
'utf-8')
as f:
6095 os.chmod(wrapper_path, 0o755)
6100 create_wrapper: bool =
True,
6101 success_command:
"list | None" =
None,
6102 post_recipe:
"str | None" =
None,
6103 postprocessor:
"str | None" =
None,
6104 recompute: bool =
False) ->
"tuple[list, dict]":
6106 @brief Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
6107 @param[in] run_dir Argument passed to `build_post_locked_command()`.
6108 @param[in] recipe_fingerprint Argument passed to `build_post_locked_command()`.
6109 @param[in] wrapped_command Argument passed to `build_post_locked_command()`.
6110 @param[in] create_wrapper Argument passed to `build_post_locked_command()`.
6111 @param[in] success_command Optional serial command run under the same lock after success.
6112 @param[in] post_recipe Recipe whose steps the wrapper plans at launch; None runs the
6114 @param[in] postprocessor Postprocessor the command launches, recorded with its output.
6115 @param[in] recompute Regenerate every step whose checkpoint is committed.
6116 @return Value returned by `build_post_locked_command()`.
6121 sys.executable, wrapper_path,
6122 '--lock-file', lock_paths[
'lock_file'],
6123 '--metadata-file', lock_paths[
'metadata_file'],
6124 '--run-dir', run_dir,
6125 '--recipe-fingerprint', recipe_fingerprint,
6128 command += [
'--post-recipe', os.path.abspath(post_recipe),
6129 '--postprocessor', os.path.abspath(postprocessor)]
6131 command.append(
'--recompute')
6133 command += [
'--success-command-json', json.dumps(
list(success_command))]
6134 command += [
'--'] +
list(wrapped_command)
6135 return command, lock_paths
6139 finalize_command: list) ->
None:
6141 @brief Refresh a collection-only result under the ordinary post writer lock.
6142 @param[in] run_dir Run whose post writer lock is acquired.
6143 @param[in] recipe_fingerprint Recipe identity recorded in lock metadata.
6144 @param[in] finalize_command Serial collection finalizer command.
6147 if not finalize_command:
6150 run_dir, recipe_fingerprint, [sys.executable,
"-c",
"pass"],
6151 create_wrapper=
True, success_command=finalize_command,
6153 result = subprocess.run(command, cwd=run_dir)
6154 if result.returncode != 0:
6156 f
"Post finalization (ParaView series, reducers) failed with exit code {result.returncode}."
6166 recompute: bool =
False,
6167 allow_source_frontier_scan: bool =
True,
6168 postprocessor_sha:
"str | None" =
None,
6171 @brief Decide which steps of a recipe's window one invocation processes.
6172 @details Every requested step is classified by classify_post_step(): valid output is
6173 kept, and missing or stale output is (re)made once its checkpoint is
6174 committed. Steps need not be contiguous. When the solver stage has not run
6175 yet the source scan is deferred and every step without valid output is
6176 planned; the post job plans again when it starts (prepare_post_job()).
6177 @param[in] run_dir Run being post-processed.
6178 @param[in] run_id Run identity recorded in the recipe state.
6179 @param[in] case_cfg Parsed case configuration.
6180 @param[in] monitor_cfg Parsed monitor configuration.
6181 @param[in] post_cfg Parsed post recipe.
6182 @param[in] recompute Regenerate every step whose checkpoint is committed.
6183 @param[in] allow_source_frontier_scan False when the solver stage has not produced output yet.
6184 @param[in] postprocessor_sha Hash of the postprocessor that would run, used only to
6185 report valid output another build made.
6186 @return Plan mapping; see the keys of the returned dictionary.
6188 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
6192 run_dir, post_cfg, requested_start_step, step_interval
6201 source_frontier_deferred =
not allow_source_frontier_scan
6203 source_frontier_step =
None
6204 source_frontier_diagnostic =
None
6205 if not source_frontier_deferred:
6210 resolved_source_dir, monitor_cfg, resolved_post_cfg,
6211 owned_start_step, requested_end_step, step_interval,
6213 source_frontier_step = source_frontier_info[
'frontier_step']
6214 source_frontier_diagnostic = source_frontier_info[
'diagnostic']
6217 process, unavailable, reasons, adopted = [], [], {}, {}
6218 current = other_build = 0
6219 for step
in _iter_post_steps(owned_start_step, requested_end_step, step_interval):
6220 record = records.get(str(step))
6221 found = outputs(step)
6222 files =
None if found
is None else found[0] + found[1]
6228 record = adopted[step] = {
6229 "recipe": recipe_fingerprint,
6230 "checkpoint": commit[0]
if commit
else None,
6231 "postprocessor":
None,
6233 if postprocessor_sha
and record.get(
"postprocessor") != postprocessor_sha:
6235 elif available
is None or step
in available:
6236 process.append(step)
6237 reasons[reason] = reasons.get(reason, 0) + 1
6239 unavailable.append(step)
6244 skip_reason =
'already-complete-window'
6246 skip_reason =
'already-caught-up-to-current-source-frontier'
6248 skip_reason =
'nothing-available-yet'
6249 effective_start_step = process[0]
if process
else None
6250 effective_end_step = process[-1]
if process
else None
6251 effective_post_cfg =
None
6252 if skip_reason
is None:
6254 post_cfg, resolved_source_dir,
6255 start_step=effective_start_step, end_step=effective_end_step,
6260 'recompute': bool(recompute),
6261 'requested_start_step': requested_start_step,
6262 'requested_end_step': requested_end_step,
6263 'owned_start_step': owned_start_step,
6264 'step_interval': step_interval,
6265 'source_data_directory': resolved_source_dir,
6266 'recipe_config': recipe_cfg,
6267 'recipe_signature': recipe_signature,
6268 'recipe_fingerprint': recipe_fingerprint,
6269 'resume_state_path': state_path,
6270 'steps_to_process': process,
6271 'steps_unavailable': unavailable,
6272 'steps_current': current,
6273 'steps_other_build': other_build,
6274 'process_reasons': reasons,
6275 'adopted_records': adopted,
6276 'source_frontier_step': source_frontier_step,
6277 'source_frontier_diagnostic': source_frontier_diagnostic,
6278 'source_frontier_deferred': source_frontier_deferred,
6279 'source_committed_count':
None if available
is None else len(available),
6280 'source_latest_step': max(available)
if available
else None,
6281 'effective_start_step': effective_start_step,
6282 'effective_end_step': effective_end_step,
6283 'skip_reason': skip_reason,
6284 'resolved_post_cfg': resolved_post_cfg,
6285 'effective_post_cfg': effective_post_cfg,
6291POST_PROCESS_REASON_LABELS = {
6292 "missing":
"no output yet",
6293 "recipe":
"recipe changed",
6294 "checkpoint":
"checkpoint changed",
6295 "recompute":
"--recompute",
6301 @brief Print which steps a post plan processes, keeps, and waits for.
6302 @param[in] plan Plan from build_post_execution_plan().
6305 process = plan[
'steps_to_process']
6306 if plan[
'source_frontier_deferred']:
6307 print(f
"[INFO] Post steps: {len(process)} planned; the post job decides when it starts, "
6308 "after the solver has written them.")
6310 why =
", ".join(f
"{count} {POST_PROCESS_REASON_LABELS.get(reason, reason)}"
6311 for reason, count
in sorted(plan[
'process_reasons'].items()))
6312 span = f
" ({process[0]}..{process[-1]}; {why})" if process
else ""
6313 print(f
"[INFO] Post steps: {len(process)} to process{span}, {plan['steps_current']} up to date, "
6314 f
"{len(plan['steps_unavailable'])} without a committed checkpoint.")
6315 if plan[
'steps_other_build']:
6316 print(f
"[INFO] {plan['steps_other_build']} up-to-date step(s) were made by another or an "
6317 "unrecorded postprocessor build and are kept; --recompute regenerates them.")
6322 @brief Return the file naming the steps a launched post job processes.
6323 @param[in] run_dir Run directory.
6324 @param[in] post_cfg Runtime recipe with canonical paths.
6325 @return Absolute path; a companion `<path>.json` holds what recording needs.
6332 @brief Load the configuration a post job needs from the run it runs in.
6333 @param[in] run_dir Run directory.
6334 @param[in] post_recipe_path The recipe the job was staged with.
6335 @return (case configuration, monitor configuration, runtime recipe with canonical paths).
6338 if not (case_path
and monitor_path):
6339 raise ValueError(f
"Could not identify the case and monitor files of {run_dir}.")
6345 recompute: bool =
False) -> int:
6347 @brief Decide, as a post job starts, which steps it processes.
6348 @details Runs under the post lock immediately before the postprocessor, so a job
6349 staged long before it runs still skips output produced in the meantime and
6350 sees checkpoints the solver has written since. Writes the step list the
6351 postprocessor reads, and records what finish_post_job() needs.
6352 @param[in] run_dir Run directory.
6353 @param[in] post_recipe_path The recipe the job was staged with.
6354 @param[in] postprocessor Postprocessor executable the job launches.
6355 @param[in] recompute Regenerate every step whose checkpoint is committed.
6356 @return Number of steps to process; 0 means the postprocessor need not run.
6358 run_dir = os.path.abspath(run_dir)
6361 run_id = read_artifact_identity(run_dir)[
"run_id"]
6363 run_id = os.path.basename(run_dir)
6366 recompute=recompute, postprocessor_sha=sha)
6370 steps = plan[
'steps_to_process']
if plan[
'skip_reason']
is None else []
6372 for path
in (list_path, list_path +
".json"):
6373 if os.path.exists(path):
6379 found = outputs(step)
6380 before[str(step)] = {path: os.stat(path).st_mtime_ns
6381 for path
in (found[0] + found[1]
if found
else [])}
6383 "postprocessor": sha,
6384 "outputs_before": before,
6385 "plan": {key: plan[key]
for key
in (
6386 "run_id",
"recipe_fingerprint",
"recipe_signature",
"requested_start_step",
6387 "requested_end_step",
"step_interval",
"source_data_directory",
"resume_state_path")},
6389 with open(list_path,
"w", encoding=
"utf-8")
as stream:
6390 stream.write(
"".join(f
"{step}\n" for step
in steps))
6394def finish_post_job(run_dir: str, post_recipe_path: str, exited_cleanly: bool =
False) -> int:
6396 @brief Record, after a post job ends, which steps it produced.
6397 @details Runs whether or not the postprocessor succeeded, so the steps a failed or
6398 interrupted job completed are kept. A step is credited only when all its
6399 output exists and was written by this job.
6400 @param[in] run_dir Run directory.
6401 @param[in] post_recipe_path The recipe the job was staged with.
6402 @param[in] exited_cleanly Whether the postprocessor exited with status zero; a clean
6403 exit that left an expected output unwritten is reported.
6404 @return Number of steps recorded.
6406 run_dir = os.path.abspath(run_dir)
6410 if not isinstance(job, dict):
6413 source = plan[
"source_data_directory"]
6418 for step_text, before
in job[
"outputs_before"].items():
6419 step = int(step_text)
6420 found = outputs(step)
6422 incomplete.append(step)
6426 written = found[0]
or found[1]
6427 if not written
or any(before.get(path) == os.stat(path).st_mtime_ns
for path
in written):
6431 "recipe": plan[
"recipe_fingerprint"],
6432 "checkpoint": commit[0]
if commit
else None,
6433 "postprocessor": job[
"postprocessor"],
6436 for path
in (list_path, list_path +
".json"):
6437 if os.path.exists(path):
6439 print(f
"[INFO] Post job recorded {len(records)} of {len(job['outputs_before'])} planned step(s).")
6443 if incomplete
and exited_cleanly:
6447 executable =
"the run's postprocessor"
6448 print(f
"[WARNING] The postprocessor exited cleanly, but {len(incomplete)} planned step(s) "
6449 f
"lack an output this recipe expects (first: step {min(incomplete)}). It may "
6450 f
"predate a requested output: {executable} ignores recipe keys it does not know. "
6451 "Check its build with 'picurv version', or post-process a branch of the run "
6452 "built from the current source.", file=sys.stderr)
6458 @brief Return True when the solver requires restart data from disk.
6459 @details Correctly identifies that analytical + init + start_step > 0 does NOT
6460 need a restart source (C code never reads from restart_dir in that case).
6461 @param[in] case_cfg Parsed case YAML dictionary.
6462 @param[in] solver_cfg Parsed solver YAML dictionary.
6463 @return True if a restart source (--restart-from or --continue) is required.
6466 start_step = int(case_cfg.get(
"run_control", {}).get(
"start_step", 0)
or 0)
6467 except (TypeError, ValueError):
6469 eulerian_source = str(
6470 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
6472 particle_restart_mode = str(
6473 (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"particles", {})
or {}).get(
"restart_mode",
"init")
6475 euler_needs = (eulerian_source ==
"load")
or (eulerian_source ==
"solve" and start_step > 0)
6476 particle_needs = (particle_restart_mode ==
"load")
6477 return euler_needs
or particle_needs
6482 @brief Resolve the output data directory within a run directory.
6483 @param[in] run_dir Path to the run directory.
6484 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6485 @return Absolute path to the output directory.
6488 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"]))
6493 @brief Resolve the restart staging directory within a run directory.
6494 @param[in] run_dir Path to the run directory.
6495 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6496 @return Absolute path to the restart directory.
6499 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS[
"restart"]))
6504 @brief Compute the hidden identity used to guard in-place continuation.
6505 @details Run length/timestep controls, the MPI domain decomposition, and particle
6506 load-vs-init policy do not define the physical case. All other case.yml
6507 content does. Checkpoints are decomposition-independent, so a run may
6508 continue on a different rank layout.
6509 @param[in] case_cfg Parsed case configuration.
6510 @return Lowercase SHA-256 identity of normalized physical-case content.
6512 normalized = copy.deepcopy(case_cfg)
6513 if isinstance(normalized, dict):
6514 normalized.pop(
"run_control",
None)
6515 grid = normalized.get(
"grid")
6516 if isinstance(grid, dict):
6517 for key
in GRID_DA_PROCESSOR_KEYS:
6519 if isinstance(grid.get(
"programmatic_settings"), dict):
6520 for key
in GRID_DA_PROCESSOR_KEYS:
6521 grid[
"programmatic_settings"].pop(key,
None)
6522 particles = (((normalized.get(
"models")
or {}).get(
"physics")
or {}).get(
"particles"))
6523 if isinstance(particles, dict):
6524 particles.pop(
"restart_mode",
None)
6525 payload = json.dumps(
6526 normalized, sort_keys=
True, separators=(
",",
":"), ensure_ascii=
False
6528 return hashlib.sha256(payload).hexdigest()
6533 @brief Reject in-place continuation when the physical case has changed.
6534 @param[in] run_dir Existing run directory being continued.
6535 @param[in] case_cfg Newly requested case configuration.
6538 saved_case_path = os.path.join(run_dir,
"config",
"case.yml")
6539 if not os.path.isfile(saved_case_path):
6540 raise ValueError(f
"--continue run is missing its saved case.yml: {saved_case_path}")
6544 "--continue cannot change the physical case. Change only run_control, the "
6545 "da_processors layout, solver.yml, monitor.yml, or post.yml; use --restart-from "
6546 "for a new case branch."
6551 monitor_cfg: dict, end_step:
"int | None" =
None,
6552 materialize: bool =
True):
6554 @brief Atomically materialize an immutable checkpoint interval into a run.
6555 @param[in] source_output Path to the source output directory containing checkpoint data.
6556 @param[in] target_restart Path to the target restart directory to populate.
6557 @param[in] start_step First checkpoint step to materialize.
6558 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6559 @param[in] end_step Optional inclusive last checkpoint step.
6560 @param[in] materialize Whether to copy the bundles, or only validate and resolve
6561 the path a real run would use. The dry-run planner needs the second:
6562 it reports what a run would refuse, and promises to write nothing.
6563 @return Canonical restart root containing committed checkpoint bundles.
6566 checkpoints_root = os.path.join(os.path.abspath(target_restart),
"checkpoints")
6568 os.makedirs(checkpoints_root, exist_ok=
True)
6569 final_step = start_step
if end_step
is None else end_step
6570 if final_step < start_step:
6571 raise ValueError(
"Restart checkpoint interval end must not precede its start.")
6572 for step
in range(start_step, final_step + 1):
6576 destination = os.path.join(
6577 checkpoints_root, f
"step_{step:0{CHECKPOINT_STEP_WIDTH}d}"
6579 if os.path.isdir(destination):
6581 print(f
"[INFO] Reusing committed restart bundle: {destination}")
6583 temporary = os.path.join(
6585 f
".step_{step:0{CHECKPOINT_STEP_WIDTH}d}.copying.{os.getpid()}",
6587 if os.path.exists(temporary):
6588 raise ValueError(f
"Restart staging path already exists: {temporary}")
6590 os.makedirs(temporary)
6591 for current, dirnames, filenames
in os.walk(source):
6592 relative = os.path.relpath(current, source)
6593 target_dir = temporary
if relative ==
"." else os.path.join(temporary, relative)
6594 os.makedirs(target_dir, exist_ok=
True)
6595 for dirname
in dirnames:
6596 os.makedirs(os.path.join(target_dir, dirname), exist_ok=
True)
6597 for filename
in filenames:
6599 os.path.join(current, filename), os.path.join(target_dir, filename)
6602 os.replace(temporary, destination)
6604 if os.path.isdir(temporary):
6605 shutil.rmtree(temporary)
6607 print(f
"[INFO] Materialized committed restart bundle for step {step}: {destination}")
6608 return os.path.abspath(target_restart)
6613 @brief Validate the mandatory Eulerian field set required by `ReadSimulationFields()`.
6614 @param[in] source_dir Root directory containing the Eulerian subdirectory.
6615 @param[in] step Checkpoint step to validate.
6616 @param[in] monitor_cfg Monitor configuration defining the Eulerian subdirectory.
6617 @return Validated checkpoint description.
6618 @throws ValueError if any mandatory Eulerian field is absent.
6626 @brief Scan output directory for the highest step number available.
6627 @details Ignores incomplete temporary directories and invalid bundles.
6628 @param[in] output_dir Path to the output directory.
6629 @return The highest step number found, or None if no checkpoints exist.
6632 return max(steps)
if steps
else None
6637 @brief Determine whether a study case is complete, partially complete, or empty.
6638 @param[in] run_dir Path to the case run directory.
6639 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6640 @param[in] target_final_step The step number the case should reach for completion.
6641 @return Dictionary with keys 'last_step' (int or None), 'target_step' (int),
6642 and 'status' ('complete', 'partial', or 'empty').
6646 if last_step
is not None and last_step >= target_final_step:
6648 elif last_step
is not None:
6652 return {
"last_step": last_step,
"target_step": target_final_step,
"status": status}
6657 @brief Validate that all required eulerian step files exist for "load" mode.
6658 @details Checks that ufield files exist for every step from start_step through
6659 start_step + total_steps (inclusive). Reports missing steps clearly.
6660 @param[in] source_output Path to the output directory containing eulerian data.
6661 @param[in] start_step First step that will be loaded.
6662 @param[in] total_steps Number of steps to run.
6663 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6666 for step
in range(start_step, start_step + total_steps + 1):
6669 except CheckpointIndexWidthError:
6672 missing.append(step)
6675 sample = missing[:3] + ([
"..."]
if len(missing) > 6
else []) + missing[-3:]
6677 f
"Eulerian 'load' mode: {len(missing)} committed checkpoint(s) missing in {source_output}. "
6678 f
"Missing steps include: {sample}"
6684 @brief Validate that particle checkpoint files exist for the given step.
6685 @details Checks that at least a position file exists at the expected step in
6686 the particle subdirectory.
6687 @param[in] source_dir Path to the directory containing the particle subdirectory.
6688 @param[in] start_step The step number whose particle checkpoint is expected.
6689 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6690 @return Validated checkpoint description.
6698 @brief Read the monitor.yml from a run directory's config/ subdirectory.
6699 @param[in] run_dir Path to the run directory.
6700 @return Parsed monitor YAML dictionary.
6702 monitor_path = os.path.join(run_dir,
"config",
"monitor.yml")
6703 if not os.path.isfile(monitor_path):
6704 raise ValueError(f
"Run directory is missing config/monitor.yml: {monitor_path}")
6710 @brief Select the newest local workspace run compatible with a requested restart.
6711 @param[in] case_cfg Current case configuration.
6712 @param[in] case_path Current case path.
6713 @param[in] start_step Required committed checkpoint.
6714 @return Absolute source run directory.
6717 if not workspace_root:
6718 raise ValueError(
"--from latest requires an initialized workspace.")
6720 current_grid = next(
6721 (item
for item
in current_graph.get(
"providers", [])
if item.get(
"kind") ==
"grid"),
6725 for candidate
in Path(workspace_root,
"runs").iterdir():
6726 if not candidate.is_dir():
6729 lock =
read_yaml_file(str(candidate /
"inputs" /
"assets.lock.yml")) \
6730 if (candidate /
"inputs" /
"assets.lock.yml").is_file()
else {}
6731 locked_grid = (lock.get(
"assets")
or {}).get(
"grid")
6732 runtime_grid = (lock.get(
"runtime_providers")
or {}).get(
"grid")
6734 recorded_hash =
None
6735 if isinstance(locked_grid, dict):
6736 recorded_hash = locked_grid.get(
"provider_spec_sha256")
6737 elif isinstance(runtime_grid, dict):
6738 recorded_hash = runtime_grid.get(
"provider_spec_sha256")
6739 if recorded_hash
and recorded_hash != current_grid.get(
"spec_sha256"):
6741 output_root = candidate / CANONICAL_RUN_PATHS[
"output"]
6744 except CheckpointIndexWidthError:
6746 except (OSError, ValueError):
6748 ordering = manifest.get(
"updated_at")
or manifest.get(
"created_at")
or ""
6749 candidates.append((ordering, candidate.stat().st_mtime_ns, str(candidate)))
6752 f
"No local workspace run has a compatible committed checkpoint at step {start_step}. "
6753 "Restore a run from storage first, or name one explicitly with --from <run-dir>."
6755 candidates.sort(reverse=
True)
6756 selected = candidates[0][2]
6757 print(f
"[INFO] Selected latest compatible restart run: {os.path.relpath(selected, workspace_root)}")
6762 run_dir: str, materialize: bool =
True):
6764 @brief Resolve the restart source directory based on --restart-from or --continue CLI flags.
6765 @details Implements the full restart resolution logic including smart resolution for
6766 --continue (checks restart/ first for user-curated data, falls back to output/)
6767 and direct reference for eulerian "load" mode.
6768 @param[in] args Parsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
6769 @param[in] case_cfg Parsed case YAML dictionary.
6770 @param[in] solver_cfg Parsed solver YAML dictionary.
6771 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6772 @param[in] run_dir Path to the current run directory.
6773 @param[in] materialize Whether to copy restart bundles into the run. The dry-run
6774 planner passes False: it still validates and resolves, but writes nothing.
6775 @return Tuple of (restart_source_dir, continue_mode, lineage) where restart_source_dir
6776 is the resolved path (or None), continue_mode is a boolean, and lineage is the
6777 branch provenance record from `build_run_lineage()`, or None when this run did
6778 not branch from another.
6781 start_step = int(case_cfg.get(
"run_control", {}).get(
"start_step", 0)
or 0)
6782 except (TypeError, ValueError):
6785 total_steps = int(case_cfg.get(
"run_control", {}).get(
"total_steps", 0)
or 0)
6786 except (TypeError, ValueError):
6789 eulerian_source = str(
6790 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
6793 particle_restart_mode = str(
6794 (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"particles", {})
or {}).get(
"restart_mode",
"init")
6796 particle_needs = (particle_restart_mode ==
"load")
6799 restart_from = getattr(args,
'restart_from',
None)
6800 continue_run = getattr(args,
'continue_run',
False)
6802 if restart_from
and continue_run:
6803 raise ValueError(
"--restart-from and --continue are mutually exclusive.")
6806 if continue_run
and getattr(args,
'run_dir',
None)
and not os.path.isdir(args.run_dir):
6807 raise ValueError(f
"--run-dir does not exist: {os.path.abspath(args.run_dir)}")
6808 if continue_run
and start_step <= 0:
6810 "--continue with --solve requires run_control.start_step > 0; "
6811 "start_step=0 is a fresh start. Omit --continue to start a fresh run."
6815 requested_restart_source = str(restart_from)
6816 if str(restart_from).strip().lower() ==
"latest":
6818 case_cfg, getattr(args,
"case",
"case.yml"), start_step
6826 requested_statistics_state = getattr(args,
"statistics_state",
None)
6828 if requested_statistics_state
is None:
6829 if statistics_enabled:
6831 "A branched restart of a run with field_statistics.enabled: true must "
6832 "state what happens to the parent's accumulated windows. Pass "
6833 "--statistics-state reset to discard them and start the averages over, "
6834 "or --statistics-state carry to resume compatible saved window state."
6836 requested_statistics_state =
"reset"
6837 statistics_state = str(requested_statistics_state).lower()
6838 if statistics_state ==
"carry" and not statistics_enabled:
6839 raise ValueError(
"--statistics-state carry requires field_statistics.enabled: true.")
6843 if statistics_state ==
"carry":
6844 requires_source =
True
6847 source_run = os.path.abspath(restart_from)
6848 if not os.path.isdir(source_run):
6849 raise ValueError(f
"--restart-from run directory does not exist: {source_run}")
6851 require_storage_payload_local(source_run,
"--restart-from", checkpoint=start_step)
6852 except StorageError
as exc:
6853 raise ValueError(str(exc))
from exc
6856 if not os.path.isdir(source_output):
6857 raise ValueError(f
"Source output directory does not exist: {source_output}")
6859 source_run, start_step,
6861 statistics_state=statistics_state,
6862 requested_source=requested_restart_source,
6865 if not requires_source:
6868 "[WARNING] --restart-from has no effect: an analytical flow with freshly "
6869 "initialized particles reads no restart data.",
6872 return None,
False,
None
6874 if eulerian_source ==
"load":
6880 source_output, target_restart, start_step, monitor_cfg,
6881 end_step=start_step + total_steps, materialize=materialize,
6885 restart_root
if materialize
else source_output, start_step, monitor_cfg
6887 return restart_root,
False, lineage
6892 source_output, target_restart, start_step, monitor_cfg,
6893 materialize=materialize,
6897 restart_root
if materialize
else source_output, start_step, monitor_cfg
6899 return restart_root,
False, lineage
6903 continue_run_dir = getattr(args,
'run_dir',
None)
6904 if not continue_run_dir:
6905 raise ValueError(RESTART_RUN_DIR_REQUIRED_MESSAGE)
6906 continue_run_dir = os.path.abspath(continue_run_dir)
6908 require_storage_payload_local(continue_run_dir,
"--continue", checkpoint=start_step)
6909 except StorageError
as exc:
6910 raise ValueError(str(exc))
from exc
6917 if last_step
is not None and start_step < last_step:
6919 f
"--continue starts at step {start_step}, but this run already has committed "
6920 f
"checkpoints through step {last_step}. Continuing in place from an earlier "
6921 "checkpoint would keep the later ones from the old trajectory. Set "
6922 f
"run_control.start_step to {last_step} to extend the run, or branch a new run "
6923 f
"from step {start_step} with --restart-from {continue_run_dir}."
6925 if last_step
is not None and start_step > last_step:
6927 f
"--continue starts at step {start_step}, but this run's last committed "
6928 f
"checkpoint is step {last_step}. Set run_control.start_step to {last_step}."
6931 if eulerian_source ==
"load":
6936 source_output, target_restart, start_step, monitor_cfg,
6937 end_step=start_step + total_steps, materialize=materialize,
6941 restart_root
if materialize
else source_output, start_step, monitor_cfg
6943 return restart_root,
True,
None
6944 elif not requires_source:
6946 return None,
True,
None
6952 source_output, target_restart, start_step, monitor_cfg,
6953 materialize=materialize,
6956 restart_root
if materialize
else source_output, start_step,
6957 require_particles=particle_needs,
6959 return restart_root,
True,
None
6961 elif requires_source:
6963 "Restart data required but no source specified. Use:\n"
6964 " --restart-from <run_dir> (new run from another run's data)\n"
6965 " --continue --run-dir <run_dir> (resume in same directory)"
6968 return None,
False,
None
6972 @brief Convert external grid/generator paths in case config to absolute paths.
6973 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
6974 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
6981 grid_cfg = case_cfg.get(
"grid", {})
6982 if not isinstance(grid_cfg, dict):
6984 mode = grid_cfg.get(
"mode")
6986 source_file = grid_cfg.get(
"source_file")
6987 if isinstance(source_file, str):
6988 grid_cfg[
"source_file"] =
resolve_path(case_anchor_path, source_file)
6989 elif mode ==
"grid_gen":
6990 gen = grid_cfg.get(
"generator", {})
6991 if isinstance(gen, dict):
6992 for key
in (
"script",
"config_file"):
6994 if isinstance(val, str):
6996 ic = (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {})
6997 if isinstance(ic, dict):
6998 if str(ic.get(
"mode",
"")).strip().lower() ==
"file":
6999 source_file = ic.get(
"source_file")
7000 if isinstance(source_file, str):
7001 ic[
"source_file"] =
resolve_path(case_anchor_path, source_file)
7002 elif str(ic.get(
"generator",
"")).strip().lower() ==
"ic_gen":
7003 params = ic.get(
"params", {})
7004 if isinstance(params, dict):
7005 for key
in (
"script",
"config_file"):
7006 value = params.get(key)
7007 if isinstance(value, str):
7009 boundary_conditions = case_cfg.get(
"boundary_conditions", [])
7010 blocks = boundary_conditions
if boundary_conditions
and isinstance(boundary_conditions[0], list)
else [boundary_conditions]
7011 for block
in blocks:
7012 if not isinstance(block, list):
7015 if not isinstance(bc, dict)
or str(bc.get(
"handler",
"")).strip().lower() !=
"prescribed_flow":
7017 source = ((bc.get(
"params")
or {}).get(
"source")
or {})
7018 if not isinstance(source, dict):
7020 source_type = str(source.get(
"type",
"")).strip().lower()
7021 if source_type ==
"file":
7023 elif source_type ==
"generated":
7025 elif source_type ==
"field_slice":
7026 keys = (
"script",
"field_file",
"grid_file",
"source_case")
7030 value = source.get(key)
7031 if isinstance(value, str):
7036 target_final_step: int, cluster_cfg: dict):
7038 @brief Set up a partially-completed study case for continuation in-place.
7039 @details Updates the case config with new start_step/total_steps, sets particle
7040 restart_mode to 'load' if checkpoint exists, populates the restart
7041 directory, and regenerates the solver control file with continue_mode.
7042 Delegates all restart resolution to resolve_restart_source().
7043 @param[in] run_dir Path to the case run directory.
7044 @param[in] case_id The case identifier (e.g. 'case_0002').
7045 @param[in] last_step The last checkpoint step found in the output directory.
7046 @param[in] target_final_step The step number the case should reach for completion.
7047 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
7048 @return The absolute path to the regenerated control file.
7050 config_dir = os.path.join(run_dir,
"config")
7052 solver_cfg =
read_yaml_file(os.path.join(config_dir,
"solver.yml"))
7053 monitor_cfg =
read_yaml_file(os.path.join(config_dir,
"monitor.yml"))
7055 remaining = target_final_step - last_step
7056 case_cfg[
"run_control"][
"start_step"] = last_step
7057 case_cfg[
"run_control"][
"total_steps"] = remaining
7058 print(f
"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
7061 particles_cfg = (case_cfg.get(
"models", {}).get(
"physics", {})
or {}).get(
"particles")
7062 checkpoint_has_particles =
False
7065 output_dir, last_step, require_particles=
True
7067 except CheckpointIndexWidthError:
7070 checkpoint_has_particles =
False
7071 if particles_cfg
is not None and checkpoint_has_particles:
7072 current_mode = str(particles_cfg.get(
"restart_mode",
"init")).strip().lower()
7073 if current_mode !=
"load":
7074 particles_cfg[
"restart_mode"] =
"load"
7075 print(f
"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
7079 mock_args = argparse.Namespace(restart_from=
None, continue_run=
True, run_dir=run_dir)
7081 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
7085 'Case': os.path.join(config_dir,
"case.yml"),
7086 'Solver': os.path.join(config_dir,
"solver.yml"),
7087 'Monitor': os.path.join(config_dir,
"monitor.yml"),
7092 "case": case_cfg,
"case_path": source_files[
'Case'],
7093 "solver": solver_cfg,
"solver_path": source_files[
'Solver'],
7094 "monitor": monitor_cfg,
"monitor_path": source_files[
'Monitor'],
7096 "cluster_cfg": cluster_cfg,
7099 run_dir, case_id, configs, cluster_tasks, monitor_files,
7100 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
7102 print(f
"[SUCCESS] {case_id}: regenerated control file for continuation")
7108 @brief Lightweight email validation for scheduler notifications.
7109 @param[in] email Argument passed to `is_valid_email()`.
7110 @return Value returned by `is_valid_email()`.
7112 if not isinstance(email, str):
7114 pattern =
r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
7115 return re.match(pattern, email.strip())
is not None
7119 @brief Normalizes user-facing statistics task names to C pipeline keywords.
7120 @param[in] task_name Task name from YAML.
7121 @return Canonical keyword accepted by C statistics pipeline.
7122 @throws ValueError if task is unsupported.
7125 if task_name
is None:
7126 raise ValueError(
"statistics task cannot be None")
7127 normalized = str(task_name).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7128 if normalized !=
"msd":
7129 raise ValueError(f
"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
7134 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
7135 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
7137 for lineno, raw
in enumerate(file_obj, start=1):
7139 if not line
or line.startswith(
"#"):
7143PICGRID_FLOAT_FORMAT =
".17e"
7148 @brief Format a coordinate with round-trip-safe binary64 precision.
7149 @param[in] value Coordinate value.
7150 @return Formatted coordinate.
7152 return format(value, PICGRID_FLOAT_FORMAT)
7157 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
7158 @details Requires canonical PICGRID input with leading "PICGRID" token.
7159 Output is always written in canonical PICGRID format with header and per-block dims.
7160 @param[in] source_grid Input grid file path.
7161 @param[in] dest_grid Output grid file path.
7162 @param[in] L_ref Reference length for non-dimensionalization.
7163 @param[in] expected_nblk Optional expected block count.
7164 @return Summary dictionary with nblk, dims, and total_nodes.
7165 @throws ValueError on malformed grid.
7168 raise ValueError(
"length_ref must be non-zero when processing grid coordinates.")
7169 if not os.path.isfile(source_grid):
7170 raise ValueError(f
"Grid file not found: {source_grid}")
7172 with open(source_grid,
"r")
as fin:
7175 _, first_token = next(line_iter)
7176 except StopIteration:
7177 raise ValueError(f
"Grid file '{source_grid}' is empty.")
7179 if first_token !=
"PICGRID":
7181 f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
7184 _, nblk_line = next(line_iter)
7185 except StopIteration:
7186 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
7189 nblk = int(nblk_line)
7191 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
7193 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
7194 if expected_nblk
is not None and nblk != expected_nblk:
7196 f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
7200 for bi
in range(nblk):
7202 lineno, dim_line = next(line_iter)
7203 except StopIteration:
7204 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
7205 parts = dim_line.split()
7208 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
7211 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
7214 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
7216 if im <= 0
or jm <= 0
or km <= 0:
7218 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
7220 dims.append((im, jm, km))
7222 total_nodes_expected = sum(im * jm * km
for (im, jm, km)
in dims)
7223 os.makedirs(os.path.dirname(dest_grid), exist_ok=
True)
7224 with open(dest_grid,
"w")
as fout:
7225 fout.write(
"PICGRID\n")
7226 fout.write(f
"{nblk}\n")
7227 for (im, jm, km)
in dims:
7228 fout.write(f
"{im} {jm} {km}\n")
7230 total_nodes_seen = 0
7231 for lineno, coord_line
in line_iter:
7232 parts = coord_line.split()
7235 f
"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
7238 x = float(parts[0]) / L_ref
7239 y = float(parts[1]) / L_ref
7240 z = float(parts[2]) / L_ref
7243 f
"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
7245 total_nodes_seen += 1
7246 if total_nodes_seen > total_nodes_expected:
7248 f
"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
7251 f
"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
7252 f
"{format_picgrid_coordinate(z)}\n"
7255 if total_nodes_seen != total_nodes_expected:
7257 f
"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
7260 return {
"nblk": nblk,
"dims": dims,
"total_nodes": total_nodes_expected}
7264 @brief Read only the canonical PICGRID header dimensions.
7265 @param[in] source_grid Input grid file path.
7266 @param[in] expected_nblk Optional expected block count.
7267 @return List of (IM, JM, KM) node-count tuples.
7268 @throws ValueError on malformed header.
7270 if not os.path.isfile(source_grid):
7271 raise ValueError(f
"Grid file not found: {source_grid}")
7273 with open(source_grid,
"r")
as fin:
7276 _, first_token = next(line_iter)
7277 except StopIteration:
7278 raise ValueError(f
"Grid file '{source_grid}' is empty.")
7279 if first_token !=
"PICGRID":
7280 raise ValueError(f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
7283 _, nblk_line = next(line_iter)
7284 nblk = int(nblk_line)
7285 except StopIteration:
7286 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
7288 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
7290 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
7291 if expected_nblk
is not None and nblk != expected_nblk:
7292 raise ValueError(f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
7295 for bi
in range(nblk):
7297 lineno, dim_line = next(line_iter)
7298 except StopIteration:
7299 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
7300 parts = dim_line.split()
7303 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
7306 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
7309 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
7311 if im <= 0
or jm <= 0
or km <= 0:
7313 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
7315 dims.append((im, jm, km))
7320 expected_dims: tuple =
None) -> dict:
7322 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
7323 @param[in] source_slice Input PICSLICE path.
7324 @param[in] dest_slice Output staged PICSLICE path.
7325 @param[in] U_ref Reference velocity for non-dimensionalization.
7326 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
7327 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
7328 @throws ValueError on malformed slice.
7331 raise ValueError(
"velocity_ref must be non-zero when processing PICSLICE speeds.")
7332 if not os.path.isfile(source_slice):
7333 raise ValueError(f
"PICSLICE file not found: {source_slice}")
7335 with open(source_slice,
"r")
as fin:
7338 _, first_token = next(line_iter)
7339 except StopIteration:
7340 raise ValueError(f
"PICSLICE file '{source_slice}' is empty.")
7341 if first_token !=
"PICSLICE":
7342 raise ValueError(f
"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
7345 _, frame_line = next(line_iter)
7346 frame_count = int(frame_line)
7347 except StopIteration:
7348 raise ValueError(f
"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
7350 raise ValueError(f
"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
7351 if frame_count != 1:
7353 f
"PICSLICE file '{source_slice}' has frame count {frame_count}; PICurv reads exactly one frame."
7357 lineno, dim_line = next(line_iter)
7358 except StopIteration:
7359 raise ValueError(f
"PICSLICE file '{source_slice}' missing slice dimensions.")
7360 parts = dim_line.split()
7363 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
7366 n1, n2 = (int(parts[0]), int(parts[1]))
7369 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
7371 if n1 <= 0
or n2 <= 0:
7372 raise ValueError(f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
7373 if expected_dims
is not None and (n1, n2) != tuple(expected_dims):
7375 f
"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
7379 for lineno, value_line
in line_iter:
7380 parts = value_line.split()
7383 f
"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
7386 value = float(parts[0])
7388 raise ValueError(f
"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
7389 if not math.isfinite(value):
7390 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be finite.")
7392 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
7393 values.append(value)
7395 expected_count = n1 * n2
7396 if len(values) != expected_count:
7398 f
"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
7401 os.makedirs(os.path.dirname(dest_slice), exist_ok=
True)
7402 with open(dest_slice,
"w")
as fout:
7403 fout.write(
"PICSLICE\n")
7405 fout.write(f
"{n1} {n2}\n")
7406 for value
in values:
7407 fout.write(f
"{value / U_ref:.8e}\n")
7410 "frame_count": frame_count,
7412 "value_count": len(values),
7413 "min_speed": min(values)
if values
else 0.0,
7414 "max_speed": max(values)
if values
else 0.0,
7419 @brief Convert a BC face token into a filesystem-friendly artifact token.
7420 @param[in] face Canonical face token such as -Zeta.
7421 @return Filesystem-friendly face token.
7423 return face.replace(
"+",
"pos").replace(
"-",
"neg")
7426 default_to_config_dir: bool =
False) -> str:
7428 @brief Resolve a run artifact path with run-dir-relative defaults.
7429 @param[in] run_dir Run/precompute directory root.
7430 @param[in] configured_path Optional user-provided artifact path.
7431 @param[in] default_path Default path relative to run_dir.
7432 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
7433 @return Absolute artifact path.
7435 path = configured_path
if configured_path
else default_path
7436 if not isinstance(path, str)
or not path.strip():
7437 raise ValueError(
"generated profile output_file must be a non-empty path when provided.")
7439 if os.path.isabs(path):
7440 return os.path.abspath(path)
7441 if default_to_config_dir
and os.path.dirname(path) ==
"":
7442 path = os.path.join(
"config", path)
7443 return os.path.abspath(os.path.join(run_dir, path))
7447 @brief Resolve an optional generator script override or repository default.
7448 @param[in] configured_script Optional absolute or case-relative script path.
7449 @param[in] case_path Current case.yml path used to anchor relative overrides.
7450 @param[in] default_name Repository generator filename under GENERATORS_PATH.
7451 @return Absolute generator script path.
7453 if configured_script
is None:
7454 return os.path.join(GENERATORS_PATH, default_name)
7455 if not isinstance(configured_script, str)
or not configured_script.strip():
7456 raise ValueError(f
"Generator script override for {default_name} must be a non-empty path.")
7457 script = configured_script.strip()
7458 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
7463 @brief Validate square-duct Poiseuille generator parameters.
7464 @param[in] params Generator params mapping.
7465 @param[in] field_name Human-readable YAML field name for diagnostics.
7466 @return Normalized params.
7470 if not isinstance(params, dict):
7471 raise ValueError(f
"{field_name}.params must be a mapping when provided.")
7472 unknown = sorted(set(params.keys()) - {
"bulk_velocity",
"n_terms"})
7474 raise ValueError(f
"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
7475 bulk_velocity =
_to_float(params.get(
"bulk_velocity", 1.0), f
"{field_name}.params.bulk_velocity")
7476 if bulk_velocity <= 0.0:
7477 raise ValueError(f
"{field_name}.params.bulk_velocity must be positive.")
7479 n_terms = int(params.get(
"n_terms", 101))
7480 except (TypeError, ValueError):
7481 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
7482 if n_terms <= 0
or n_terms % 2 == 0:
7483 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
7484 return {
"bulk_velocity": bulk_velocity,
"n_terms": n_terms}
7486GENERATED_PROFILE_GENERATORS = {
"square_duct_poiseuille"}
7490 @brief Validate a prescribed_flow field_slice source block.
7491 @param[in] source Source mapping from case.yml.
7492 @param[in] field_name Human-readable YAML path for diagnostics.
7493 @return Normalized source mapping.
7505 if "output_file" in source:
7507 unknown = sorted(set(source.keys()) - allowed)
7509 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
7510 field_file = source.get(
"field_file")
7511 grid_file = source.get(
"grid_file")
7512 if not isinstance(field_file, str)
or not field_file.strip():
7513 raise ValueError(f
"{field_name}.field_file must be a non-empty path.")
7514 if not isinstance(grid_file, str)
or not grid_file.strip():
7515 raise ValueError(f
"{field_name}.grid_file must be a non-empty path.")
7516 if source.get(
"source_case")
is None and source.get(
"velocity_scale")
is None:
7517 raise ValueError(f
"{field_name} requires source_case or velocity_scale.")
7520 "type":
"field_slice",
7521 "field_file": field_file.strip(),
7522 "grid_file": grid_file.strip(),
7525 if source.get(
"script")
is not None:
7526 script = source.get(
"script")
7527 if not isinstance(script, str)
or not script.strip():
7528 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
7529 normalized[
"script"] = script.strip()
7530 if source.get(
"source_case")
is not None:
7531 source_case = source.get(
"source_case")
7532 if not isinstance(source_case, str)
or not source_case.strip():
7533 raise ValueError(f
"{field_name}.source_case must be a non-empty path when provided.")
7534 normalized[
"source_case"] = source_case.strip()
7535 if source.get(
"velocity_scale")
is not None:
7536 velocity_scale =
_to_float(source.get(
"velocity_scale"), f
"{field_name}.velocity_scale")
7537 if velocity_scale <= 0.0:
7538 raise ValueError(f
"{field_name}.velocity_scale must be positive.")
7539 normalized[
"velocity_scale"] = velocity_scale
7540 if source.get(
"source_block")
is not None:
7542 source_block = int(source.get(
"source_block"))
7543 except (TypeError, ValueError):
7544 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
7545 if source_block < 0:
7546 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
7547 normalized[
"source_block"] = source_block
7552 @brief Validate the field_slice slice selector.
7553 @param[in] slice_cfg Slice selector mapping.
7554 @param[in] field_name Human-readable YAML path for diagnostics.
7555 @return Normalized selector mapping.
7557 if not isinstance(slice_cfg, dict):
7558 raise ValueError(f
"{field_name} must be a mapping.")
7559 orientation = str(slice_cfg.get(
"orientation",
"opposite")).strip().lower()
7560 if orientation
not in {
"opposite",
"same"}:
7561 raise ValueError(f
"{field_name}.orientation must be 'opposite' or 'same'.")
7562 normal_tolerance =
_to_float(slice_cfg.get(
"normal_tolerance", 0.99), f
"{field_name}.normal_tolerance")
7563 if normal_tolerance <= 0.0
or normal_tolerance > 1.0:
7564 raise ValueError(f
"{field_name}.normal_tolerance must be in the range (0, 1].")
7566 if slice_cfg.get(
"face")
is not None:
7567 unknown = sorted(set(slice_cfg.keys()) - {
"face",
"orientation",
"normal_tolerance"})
7570 f
"Unknown keys in {field_name}: {unknown}. "
7571 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
7573 face = str(slice_cfg.get(
"face",
"")).strip()
7574 if face.lower()
not in BC_FACE_MAP:
7575 raise ValueError(f
"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
7577 "face": BC_FACE_MAP[face.lower()],
7578 "orientation": orientation,
7579 "normal_tolerance": normal_tolerance,
7582 required = {
"axis",
"index",
"normal"}
7583 missing = sorted(key
for key
in required
if slice_cfg.get(key)
is None)
7585 raise ValueError(f
"{field_name} requires either face or axis/index/normal; missing {missing}.")
7586 unknown = sorted(set(slice_cfg.keys()) - {
"axis",
"index",
"normal",
"orientation",
"normal_tolerance"})
7589 f
"Unknown keys in {field_name}: {unknown}. "
7590 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
7592 axis = str(slice_cfg.get(
"axis",
"")).strip()
7593 axis_map = {
"xi":
"Xi",
"eta":
"Eta",
"zeta":
"Zeta"}
7594 if axis.lower()
not in axis_map:
7595 raise ValueError(f
"{field_name}.axis must be one of Xi, Eta, Zeta.")
7596 normal = str(slice_cfg.get(
"normal",
"")).strip()
7597 if normal.lower()
not in BC_FACE_MAP:
7598 raise ValueError(f
"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
7599 normal = BC_FACE_MAP[normal.lower()]
7600 if normal[1:].lower() != axis.lower():
7601 raise ValueError(f
"{field_name}.normal must use the same axis as {field_name}.axis.")
7603 index = int(slice_cfg.get(
"index"))
7604 except (TypeError, ValueError):
7605 raise ValueError(f
"{field_name}.index must be an integer.")
7607 raise ValueError(f
"{field_name}.index must be non-negative.")
7609 "axis": axis_map[axis.lower()],
7612 "orientation": orientation,
7613 "normal_tolerance": normal_tolerance,
7617 target_grid: str =
None, target_block: int = 0,
7618 target_face: str =
None, script: str =
None,
7619 case_path: str =
None) -> dict:
7621 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
7622 @param[in] output_path Path to write.
7623 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
7624 @param[in] params Normalized generator params.
7625 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
7626 @param[in] target_block Target block index when `target_grid` is provided.
7627 @param[in] target_face Target inlet face when `target_grid` is provided.
7628 @param[in] script Optional profile.gen-compatible script override.
7629 @param[in] case_path Current case.yml path used to anchor relative script overrides.
7630 @return Summary dictionary.
7632 n1, n2 = tuple(dims)
7634 if not os.path.isfile(profilegen_script):
7635 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
7639 "square_duct_poiseuille",
7646 str(float(params[
"bulk_velocity"])),
7648 str(int(params[
"n_terms"])),
7655 str(int(target_block)),
7656 f
"--target-face={target_face}",
7658 result = subprocess.run(cmd, text=
True, capture_output=
True)
7659 if result.returncode != 0:
7660 details = (result.stderr
or result.stdout
or "").strip()
7661 raise ValueError(f
"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
7663 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
7664 except (IndexError, json.JSONDecodeError)
as exc:
7665 raise ValueError(f
"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
7666 summary[
"dims"] = tuple(summary[
"dims"])
7670 target_grid: str, target_face: str, target_block: int,
7671 case_path: str) -> dict:
7673 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
7674 @param[in] output_path Path to write.
7675 @param[in] expected_dims Expected PICSLICE dimensions.
7676 @param[in] source Normalized field_slice source mapping.
7677 @param[in] target_grid Target canonical PICGRID path.
7678 @param[in] target_face Target inlet face token.
7679 @param[in] target_block Target block index.
7680 @param[in] case_path Path to current case.yml for relative source resolution.
7681 @return Summary dictionary from profile.gen.
7683 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
7688 if not os.path.isfile(profilegen_script):
7689 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
7690 n1, n2 = tuple(expected_dims)
7691 slice_cfg = source[
"slice"]
7705 str(int(source.get(
"source_block", 0))),
7707 str(int(target_block)),
7708 f
"--target-face={target_face}",
7710 slice_cfg[
"orientation"],
7711 "--normal-tolerance",
7712 str(float(slice_cfg[
"normal_tolerance"])),
7714 str(float(velocity_scale)),
7720 if "face" in slice_cfg:
7721 cmd.append(f
"--slice-face={slice_cfg['face']}")
7727 str(int(slice_cfg[
"index"])),
7728 f
"--slice-normal={slice_cfg['normal']}",
7730 result = subprocess.run(cmd, text=
True, capture_output=
True)
7731 if result.returncode != 0:
7732 details = (result.stderr
or result.stdout
or "").strip()
7733 raise ValueError(f
"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
7735 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
7736 except (IndexError, json.JSONDecodeError)
as exc:
7737 raise ValueError(f
"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
7738 summary[
"dims"] = tuple(summary[
"dims"])
7743 @brief Resolve a path relative to the current case directory.
7744 @param[in] path_value Path from case.yml.
7745 @param[in] case_dir Current case directory.
7746 @return Absolute path.
7748 if not isinstance(path_value, str)
or not path_value.strip():
7749 raise ValueError(
"path value must be a non-empty string.")
7752 if os.path.isabs(path_value):
7754 f
"absolute path {path_value!r} is not allowed in workspace case configuration; "
7755 "use 'picurv inputs import --mode reference' for an explicit external reference."
7757 resolved = os.path.abspath(os.path.join(workspace_root, path_value))
7758 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
7759 raise ValueError(f
"path {path_value!r} escapes the workspace.")
7760 if resolved.endswith(
".reference.yml")
and os.path.isfile(resolved):
7762 external = pointer.get(
"picurv_external_reference")
if isinstance(pointer, dict)
else None
7763 if not isinstance(external, str)
or not os.path.isabs(external):
7764 raise ValueError(f
"Invalid external-reference descriptor: {resolved}")
7765 if not os.path.isfile(external):
7766 raise ValueError(f
"Registered external input is unavailable: {external}")
7769 if os.path.isabs(path_value):
7770 return os.path.abspath(path_value)
7771 return os.path.abspath(os.path.join(case_dir, path_value))
7775 @brief Resolve field_slice dimensional velocity scale.
7776 @param[in] source Normalized field_slice source mapping.
7777 @param[in] case_dir Current case directory.
7778 @return Positive velocity scale.
7780 if source.get(
"velocity_scale")
is not None:
7781 return float(source[
"velocity_scale"])
7786 source_case_cfg.get(
"properties", {}).get(
"scaling", {}).get(
"velocity_ref"),
7787 "source_case.properties.scaling.velocity_ref",
7789 except AttributeError
as exc:
7790 raise ValueError(
"source_case must contain properties.scaling.velocity_ref.")
from exc
7791 if velocity_scale <= 0.0:
7792 raise ValueError(
"source_case.properties.scaling.velocity_ref must be positive.")
7793 return velocity_scale
7797 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
7798 @param[in] case_cfg Parsed current case config.
7799 @param[in] case_path Current case.yml path.
7800 @param[in] run_dir Current run/precompute directory.
7801 @return Absolute target PICGRID path.
7803 grid_cfg = case_cfg.get(
"grid", {})
or {}
7804 grid_mode = grid_cfg.get(
"mode")
7805 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
7806 if grid_mode ==
"file":
7807 source_grid = grid_cfg.get(
"source_file")
7808 if not isinstance(source_grid, str)
or not source_grid.strip():
7809 raise ValueError(
"grid.source_file is required for field_slice target-grid normals.")
7812 if grid_mode ==
"grid_gen":
7813 candidate = os.path.abspath(os.path.join(run_dir,
"inputs",
"grid",
"grid.generated.picgrid"))
7814 if os.path.isfile(candidate):
7816 staged = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
7817 if os.path.isfile(staged):
7819 raise ValueError(
"field_slice requires the generated target PICGRID to exist before profile extraction.")
7821 f
"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
7826 @brief Resolve the target canonical PICGRID for generated profile sampling.
7827 @details The simulator builds a programmatic_c grid itself, so no grid file exists
7828 here. The profile is sampled instead on the bridge PICGRID that a Python
7829 initial condition already uses, written with the solver's own node formula.
7830 Without a grid the generator could only sample uniform logical points and
7831 normalize to the continuous-area mean, which delivered about 2/n too much
7832 flux on n cells across (programmatic-inlet-flux-2026-09-18).
7833 @param[in] case_cfg Parsed current case config.
7834 @param[in] case_path Current case.yml path.
7835 @param[in] run_dir Current run/precompute directory.
7836 @return Absolute target PICGRID path.
7838 grid_cfg = case_cfg.get(
"grid", {})
or {}
7839 if grid_cfg.get(
"mode") ==
"programmatic_c":
7840 bridge = os.path.abspath(os.path.join(run_dir,
"inputs",
"grid",
"grid.run"))
7841 if not os.path.isfile(bridge):
7843 grid_cfg.get(
"programmatic_settings", {}), bridge,
7852 @brief Write a profile.info summary for generated inlet profiles.
7853 @param[in] config_dir Run/precompute config directory.
7854 @param[in] summaries Generated profile summaries.
7855 @return Path to profile.info.
7857 info_path = os.path.join(config_dir,
"profile.info")
7858 os.makedirs(config_dir, exist_ok=
True)
7859 with open(info_path,
"w")
as fout:
7860 fout.write(
"# PICurv generated profile summary\n")
7861 fout.write(f
"profile_count = {len(summaries)}\n\n")
7862 for idx, summary
in enumerate(summaries):
7863 dims = summary.get(
"dims", (0, 0))
7864 fout.write(f
"[profile_{idx}]\n")
7865 fout.write(f
"generator = {summary.get('generator')}\n")
7866 fout.write(f
"block = {summary.get('block')}\n")
7867 fout.write(f
"face = {summary.get('face')}\n")
7868 fout.write(f
"dimensions = {dims[0]} {dims[1]}\n")
7869 if summary.get(
"bulk_velocity")
is not None:
7870 fout.write(f
"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
7871 if summary.get(
"n_terms")
is not None:
7872 fout.write(f
"n_terms = {summary.get('n_terms')}\n")
7873 fout.write(f
"mean_speed = {summary.get('mean_speed'):.16e}\n")
7874 if "area_mean_speed" in summary:
7875 fout.write(f
"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
7876 if "discrete_mean_speed" in summary:
7877 fout.write(f
"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
7878 fout.write(f
"min_speed = {summary.get('min_speed'):.16e}\n")
7879 fout.write(f
"max_speed = {summary.get('max_speed'):.16e}\n")
7880 if summary.get(
"umax_over_ubulk")
is not None:
7881 fout.write(f
"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
7885 "area_weighted_mean_before_normalization",
7886 "area_weighted_mean_after_normalization",
7903 fout.write(f
"{key} = {summary.get(key)}\n")
7904 fout.write(f
"output_file = {summary.get('path')}\n\n")
7909GRID_GENERATOR_CHOICE_FLAGS = {
7910 "--cross-section": GRID_CROSS_SECTION_KINDS,
7914GRID_GENERATOR_SPEC_FLAGS = {
7915 "--wall-j-lo": GRID_WALL_SEGMENT_KINDS,
7916 "--wall-j-hi": GRID_WALL_SEGMENT_KINDS,
7917 "--wall-j-lo-span": GRID_WALL_SEGMENT_KINDS,
7918 "--wall-j-hi-span": GRID_WALL_SEGMENT_KINDS,
7919 "--cross-section-scale": GRID_WALL_SEGMENT_KINDS,
7920 "--path": GRID_PATH_SEGMENT_KINDS,
7921 "--transforms": GRID_TRANSFORM_KINDS,
7927 @brief Check closed-choice values inside the generator's opaque token list.
7928 @details `cli_args` is passed through to grid.gen untouched, so a misspelled geometry
7929 selector there is only discovered when the subprocess exits nonzero partway
7930 into a run. The generator remains the authority; this reports the same set
7931 earlier, and stays silent on anything it does not recognize.
7932 @param[in] cli_args Raw token list from grid.generator.cli_args.
7933 @param[in] case_path Case file path for diagnostics.
7934 @return List of error strings.
7936 if not isinstance(cli_args, list):
7938 tokens = [str(token)
for token
in cli_args]
7940 for index, token
in enumerate(tokens):
7942 for follower
in tokens[index + 1:]:
7943 if follower.startswith(
"--"):
7945 values.append(follower)
7946 if token
in GRID_GENERATOR_CHOICE_FLAGS:
7947 allowed = GRID_GENERATOR_CHOICE_FLAGS[token]
7948 for value
in values[:1]:
7949 if value
not in allowed:
7951 f
" {case_path}: grid.generator.cli_args {token} must be one of "
7952 f
"{list(allowed)} (got '{value}')."
7954 elif token
in GRID_GENERATOR_SPEC_FLAGS:
7955 allowed = GRID_GENERATOR_SPEC_FLAGS[token]
7956 for value
in values:
7957 kind = value.split(
":", 1)[0]
7958 if kind
not in allowed:
7960 f
" {case_path}: grid.generator.cli_args {token} entry '{value}' "
7961 f
"names '{kind}', which is not one of {list(allowed)}."
7967 case_cfg: dict =
None) -> str:
7969 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
7970 @param[in] case_path Path to case.yml (used for relative path resolution).
7971 @param[in] run_dir Run directory path.
7972 @param[in] grid_cfg The grid config section from case.yml.
7973 @param[in] case_cfg Parsed case configuration, when available. Its reference scales
7974 are handed to the generator so the quality report can carry
7975 solver and wall units. Reporting only: the generator never scales
7976 coordinates, which validate_and_nondimensionalize_picgrid does
7977 once for every grid regardless of origin.
7978 @return Absolute path to generated dimensional PICGRID file.
7979 @throws ValueError on invalid config or generator failure.
7981 generator = grid_cfg.get(
"generator", {})
7982 if not isinstance(generator, dict):
7983 raise ValueError(
"grid.generator must be a mapping when grid.mode is 'grid_gen'.")
7985 case_dir = os.path.dirname(os.path.abspath(case_path))
7986 gridgen_script = generator.get(
"script", os.path.join(GENERATORS_PATH,
"grid.gen"))
7987 if generator.get(
"script"):
7990 gridgen_script = os.path.abspath(gridgen_script)
7991 if not os.path.isfile(gridgen_script):
7992 raise ValueError(f
"grid.gen script not found: {gridgen_script}")
7994 config_file = generator.get(
"config_file")
7996 raise ValueError(
"grid.generator.config_file is required when grid.mode is 'grid_gen'.")
7998 if not os.path.isfile(config_file):
7999 raise ValueError(f
"grid.generator.config_file not found: {config_file}")
8001 output_file = os.path.abspath(os.path.join(run_dir,
"inputs",
"grid",
"grid.generated.picgrid"))
8002 os.makedirs(os.path.dirname(output_file), exist_ok=
True)
8004 grid_type = generator.get(
"grid_type")
8005 cli_args = generator.get(
"cli_args", [])
8006 if cli_args
is None:
8008 if not isinstance(cli_args, list):
8009 raise ValueError(
"grid.generator.cli_args must be a list of CLI tokens.")
8011 cmd = [sys.executable, gridgen_script,
"-c", config_file]
8013 cmd.append(str(grid_type))
8014 cmd.extend([str(token)
for token
in cli_args])
8015 cmd.extend([
"--output", output_file])
8020 vts_file = os.path.abspath(
8021 os.path.join(run_dir,
"output",
"visualization",
"precompute",
"grid.vts")
8023 os.makedirs(os.path.dirname(vts_file), exist_ok=
True)
8024 cmd.extend([
"--vts", vts_file])
8026 stats_file = os.path.abspath(
8027 os.path.join(run_dir,
"output",
"analysis",
"metrics",
"grid.info")
8029 os.makedirs(os.path.dirname(stats_file), exist_ok=
True)
8030 cmd.extend([
"--stats-file", stats_file])
8038 except (KeyError, ValueError, TypeError):
8041 cmd.extend([
"--length-ref", repr(scaling[
"length_ref"]),
8042 "--velocity-ref", repr(scaling[
"velocity_ref"]),
8043 "--nu", repr(scaling[
"physical_kinematic_viscosity"])])
8045 print(f
"[INFO] Grid generator command: {' '.join(cmd)}")
8046 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
8047 if result.returncode != 0:
8048 stderr = (result.stderr
or "").strip()
8049 stdout = (result.stdout
or "").strip()
8050 details = stderr
if stderr
else stdout
8052 f
"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
8055 print(result.stdout.strip())
8057 print(result.stderr.strip())
8059 if not os.path.isfile(output_file):
8060 raise ValueError(f
"grid.gen did not produce expected output file: {output_file}")
8079 "periodic":
"PERIODIC",
8085PARTICLE_BOUNDARY_POLICIES = (
"reflect",
"remove",
"wrap")
8091 "particle_policy":
"reflect",
8092 "required_params": set(),
8093 "optional_params": set(),
8098 "particle_policy":
"reflect",
8099 "required_params": set(),
8100 "optional_params": set(),
8102 "constant_velocity": {
8104 "particle_policy":
"remove",
8105 "required_params": {
"vx",
"vy",
"vz"},
8106 "optional_params": set(),
8109 "types": {
"OUTLET"},
8110 "particle_policy":
"remove",
8111 "required_params": set(),
8112 "optional_params": set(),
8116 "particle_policy":
"remove",
8117 "required_params": {
"v_max"},
8118 "optional_params": set(),
8120 "prescribed_flow": {
8122 "particle_policy":
"remove",
8123 "required_params": {
"source"},
8124 "optional_params": set(),
8127 "types": {
"PERIODIC"},
8128 "particle_policy":
"wrap",
8129 "required_params": set(),
8130 "optional_params": set(),
8133 "types": {
"PERIODIC"},
8134 "particle_policy":
"wrap",
8135 "required_params": {
"target_flux"},
8136 "optional_params": {
"enforce_seam_flux",
"apply_trim"},
8140 "types": {
"PERIODIC"},
8141 "particle_policy":
"wrap",
8142 "required_params": set(),
8143 "optional_params": {
"enforce_seam_flux",
"apply_trim"},
8147_NUMERIC_BC_PARAMS = {
"vx",
"vy",
"vz",
"v_max",
"target_flux"}
8148_BOOL_BC_PARAMS = {
"enforce_seam_flux",
"apply_trim"}
8154_DEPRECATED_BC_PARAM_ALIASES = {
"apply_trim":
"enforce_seam_flux"}
8158 @brief Validate the structured source block for prescribed_flow BCs.
8159 @param[in] source Source mapping from case.yml.
8160 @param[in] field_name Human-readable YAML path for diagnostics.
8161 @return Normalized source mapping.
8162 @throws ValueError on invalid source contract.
8164 if not isinstance(source, dict):
8165 raise ValueError(f
"{field_name} must be a mapping with type: file, generated, or field_slice.")
8166 source_type = str(source.get(
"type",
"")).strip().lower()
8167 if source_type ==
"file":
8168 path = source.get(
"path")
8169 if not isinstance(path, str)
or not path.strip():
8170 raise ValueError(f
"{field_name}.path must be a non-empty file path.")
8171 unknown = sorted(set(source.keys()) - {
"type",
"path"})
8173 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
8174 return {
"type":
"file",
"path": path.strip()}
8176 if source_type ==
"generated":
8177 generator = str(source.get(
"generator",
"")).strip().lower()
8178 if generator
not in GENERATED_PROFILE_GENERATORS:
8180 f
"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
8181 f
"(got '{source.get('generator')}')."
8183 if "output_file" in source:
8185 unknown = sorted(set(source.keys()) - {
"type",
"generator",
"script",
"params"})
8188 f
"Unknown keys in {field_name}: {unknown}. "
8189 "Allowed: ['generator', 'params', 'script', 'type']."
8192 "type":
"generated",
8193 "generator": generator,
8196 script = source.get(
"script")
8197 if script
is not None:
8198 if not isinstance(script, str)
or not script.strip():
8199 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
8200 normalized[
"script"] = script.strip()
8203 if source_type ==
"field_slice":
8206 raise ValueError(f
"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
8210 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
8211 @param[in] face Canonical BC face token.
8212 @param[in] block_dims (IM, JM, KM) node counts.
8213 @return (n1, n2) dimensions in profile storage order.
8215 im, jm, km = block_dims
8216 if min(im, jm, km) < 2:
8218 f
"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
8220 if face
in {
"-Xi",
"+Xi"}:
8221 return (km - 1, jm - 1)
8222 if face
in {
"-Eta",
"+Eta"}:
8223 return (km - 1, im - 1)
8224 if face
in {
"-Zeta",
"+Zeta"}:
8225 return (jm - 1, im - 1)
8226 raise ValueError(f
"Unsupported face '{face}' for prescribed_flow profile dimensions.")
8230 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
8231 @param[in] case_cfg Parsed case.yml configuration.
8232 @param[in] case_path Path to case.yml for relative path resolution.
8233 @param[in] run_dir Current run directory, used for optional generated grid outputs.
8234 @return List of (IM, JM, KM) node-count tuples.
8235 @throws ValueError when dimensions cannot be resolved.
8237 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
8238 grid_cfg = case_cfg.get(
"grid", {})
8239 grid_mode = grid_cfg.get(
"mode")
8240 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
8242 if grid_mode ==
"programmatic_c":
8243 settings = grid_cfg.get(
"programmatic_settings", {})
8245 for key
in (
"im",
"jm",
"km"):
8246 raw = settings.get(key)
8248 raise ValueError(f
"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
8249 if isinstance(raw, list):
8250 if len(raw) != num_blocks:
8252 f
"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
8256 values = [raw] * num_blocks
8258 values = [int(v) + 1
for v
in values]
8259 except (TypeError, ValueError):
8260 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
8261 if any(v <= 1
for v
in values):
8262 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
8263 dims_by_axis.append(values)
8264 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
8266 if grid_mode ==
"file":
8267 source_grid = grid_cfg.get(
"source_file")
8268 if not isinstance(source_grid, str)
or not source_grid.strip():
8269 raise ValueError(
"grid.source_file is required for file-grid prescribed_flow profile validation.")
8273 if grid_mode ==
"grid_gen":
8276 candidates.append(os.path.join(run_dir,
"inputs",
"grid",
"grid.generated.picgrid"))
8277 candidates.append(os.path.join(run_dir,
"inputs",
"grid",
"grid.run"))
8278 for candidate
in candidates:
8279 if os.path.isfile(candidate):
8282 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
8283 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
8286 raise ValueError(f
"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
8289 profile_grid_dims: list =
None) -> list:
8291 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
8292 @param[in] run_dir Run/precompute directory root.
8293 @param[in] case_cfg Parsed case.yml.
8294 @param[in] case_path Path to case.yml for relative grid/source resolution.
8295 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
8296 @return List of generated profile summaries.
8300 bc.get(
"handler") ==
"prescribed_flow"
8301 and ((bc.get(
"params")
or {}).get(
"source")
or {}).get(
"type")
in PRESCRIBED_FLOW_SOURCE_TYPES[1:]
8302 for block
in prepared_blocks
for bc
in block
8305 if profile_grid_dims
is None:
8308 profile_dir = os.path.join(run_dir,
"inputs",
"inlet_profiles")
8310 generated_target_grid =
None
8312 for block_idx, block
in enumerate(prepared_blocks):
8314 if bc.get(
"handler") !=
"prescribed_flow":
8316 source = (bc.get(
"params")
or {}).get(
"source", {})
8317 if source.get(
"type")
not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
8322 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
8323 output_path = os.path.join(
8325 f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice",
8327 if source.get(
"type") ==
"generated" and source[
"generator"] ==
"square_duct_poiseuille":
8328 if generated_target_grid
is None:
8334 target_grid=generated_target_grid,
8335 target_block=block_idx,
8337 script=source.get(
"script"),
8338 case_path=case_path,
8340 elif source.get(
"type") ==
"generated":
8341 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
8343 if target_grid
is None:
8354 summary.update({
"block": block_idx,
"face": face})
8355 summaries.append(summary)
8357 f
"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
8358 f
"{os.path.relpath(output_path)} dims={summary['dims']}"
8363 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
8368 @brief Convert a YAML scalar to float with a clear error message.
8369 @param[in] value Argument passed to `_to_float()`.
8370 @param[in] field_name Argument passed to `_to_float()`.
8371 @return Value returned by `_to_float()`.
8375 except (TypeError, ValueError):
8376 raise ValueError(f
"'{field_name}' must be numeric (got {value!r}).")
8381 @brief Convert a non-boolean YAML scalar to a finite float.
8382 @param[in] value Raw YAML scalar.
8383 @param[in] field_name User-facing configuration path.
8384 @return Finite floating-point value.
8386 if isinstance(value, bool):
8387 raise ValueError(f
"'{field_name}' must be numeric, not boolean.")
8389 if not math.isfinite(parsed):
8390 raise ValueError(f
"'{field_name}' must be finite (got {value!r}).")
8395 @brief Convert a YAML scalar/string to bool with a clear error message.
8396 @param[in] value Argument passed to `_to_bool()`.
8397 @param[in] field_name Argument passed to `_to_bool()`.
8398 @return Value returned by `_to_bool()`.
8400 if isinstance(value, bool):
8402 if isinstance(value, str):
8403 raw = value.strip().lower()
8404 if raw
in {
"true",
"1",
"yes"}:
8406 if raw
in {
"false",
"0",
"no"}:
8408 raise ValueError(f
"'{field_name}' must be boolean (got {value!r}).")
8412 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
8413 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
8414 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
8415 @return Value returned by `normalize_boundary_conditions_layout()`.
8417 if not all_blocks_bcs:
8418 raise ValueError(
"The 'boundary_conditions' section in case.yml is empty.")
8420 is_simple_list = isinstance(all_blocks_bcs[0], dict)
8421 if num_blocks == 1
and is_simple_list:
8422 all_blocks_bcs = [all_blocks_bcs]
8423 elif is_simple_list
and num_blocks > 1:
8425 f
"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
8426 "Use a list-of-lists, one inner list per block."
8429 if len(all_blocks_bcs) != num_blocks:
8431 f
"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
8433 return all_blocks_bcs
8437 @brief Reports which logical axes a case declares periodic on both faces.
8438 @param[in] case_cfg Parsed case.yml mapping.
8439 @return Set of axis letters drawn from {'i', 'j', 'k'}.
8441 axis_faces = {
"i": (
"-Xi",
"+Xi"),
"j": (
"-Eta",
"+Eta"),
"k": (
"-Zeta",
"+Zeta")}
8443 entries = case_cfg.get(
"boundary_conditions")
or []
8444 if entries
and isinstance(entries[0], list):
8445 entries = entries[0]
8446 for entry
in entries:
8447 if isinstance(entry, dict):
8448 declared[str(entry.get(
"face"))] = str(entry.get(
"type",
"")).upper()
8450 axis
for axis, faces
in axis_faces.items()
8451 if all(declared.get(face) ==
"PERIODIC" for face
in faces)
8459RANS_PLANNED_REFUSAL = (
8460 "models.physics.turbulence.rans is not accepted: RANS closures are planned, not "
8461 "implemented. No transport equation was ever solved for them, and the selector and "
8462 "its dead hooks were removed. Remove the block; use models.physics.turbulence.les, "
8468 case_path: str, errors: list, warnings: list):
8470 @brief Rejects wall-model selections that no turbulence treatment can support.
8472 A wall model replaces the near-wall flow with an analytic profile so that the
8473 boundary layer need not be resolved. That only means something if the unresolved
8474 motions are modelled somewhere. Three combinations cannot be, and each fails here
8475 rather than after the mesh is built.
8477 @param case_cfg Parsed case configuration, read for the Reynolds number.
8478 @param les_cfg `models.physics.turbulence.les`, or None.
8479 @param wall_cfg `models.physics.turbulence.wall_function`, or None.
8480 @param case_path Case path, for message prefixes.
8481 @param errors Collected blocking messages, appended to.
8482 @param warnings Collected advisory messages, appended to.
8484 if not isinstance(wall_cfg, dict):
8486 if not bool(wall_cfg.get(
'enabled',
False)):
8494 les_on = isinstance(les_cfg, dict)
and bool(les_cfg.get(
'enabled',
False)) \
8495 and str(les_cfg.get(
'model',
'dynamic_smagorinsky')).strip().lower() !=
'none'
8502 f
" {case_path}: models.physics.turbulence.wall_function is enabled with no "
8503 "turbulence model. A wall model supplies the stress of a boundary layer it "
8504 "does not resolve, which needs the unresolved motions modelled somewhere. "
8505 "This solver has no implicit-LES scheme to supply that - its convection is "
8506 "QUICK, whose numerical dissipation is not a subgrid model - so enable "
8507 "models.physics.turbulence.les, or resolve the wall and disable the wall "
8515 if reynolds
is not None and reynolds < 1000.0:
8517 f
" {case_path}: models.physics.turbulence.wall_function is enabled at "
8518 f
"Reynolds number {reynolds:g}, which is laminar. The log law and the "
8519 "Werner-Wengle power law both describe a turbulent boundary layer; at this "
8520 "Reynolds number there is no inertial region for either to represent, and "
8521 "the model would impose a profile the flow does not have. Resolve the wall "
8527 @brief Reynolds number implied by a case's scaling and fluid properties.
8528 @param case_cfg Parsed case configuration.
8529 @return The Reynolds number, or None when the inputs are absent or unusable.
8532 props = case_cfg.get(
'properties', {})
or {}
8533 scaling = props.get(
'scaling', {})
or {}
8534 fluid = props.get(
'fluid', {})
or {}
8535 density = float(fluid.get(
'density'))
8536 viscosity = float(fluid.get(
'viscosity'))
8537 length_ref = float(scaling.get(
'length_ref'))
8538 velocity_ref = float(scaling.get(
'velocity_ref'))
8539 except (TypeError, ValueError):
8541 if viscosity <= 0.0:
8543 return density * velocity_ref * length_ref / viscosity
8547 errors: list, warnings: list):
8549 @brief Checks the structured LES block for values the closure cannot honour.
8550 @param[in] case_cfg Parsed case.yml mapping, used to read declared periodicity.
8551 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
8552 @param[in] case_path Case file path used to prefix diagnostics.
8553 @param[out] errors List collecting blocking validation failures.
8554 @param[out] warnings List collecting advisory messages.
8555 @return None; findings are appended to `errors` and `warnings`.
8557 def _numeric(container, key, path, minimum=None, exclusive_minimum=None):
8559 @brief Reads one numeric key and records a range or type failure against it.
8560 @param[in] container Mapping holding the key.
8561 @param[in] key Key to read; absent keys are accepted silently.
8562 @param[in] path Dotted key path used in diagnostics.
8563 @param[in] minimum Inclusive lower bound, or None to impose none.
8564 @param[in] exclusive_minimum Exclusive lower bound, or None to impose none.
8565 @return The parsed value, or None when the key is absent or unparseable.
8567 if key
not in container:
8570 value = float(container[key])
8571 except (TypeError, ValueError):
8572 errors.append(f
" {case_path}: {path} must be numeric.")
8574 if minimum
is not None and value < minimum:
8575 errors.append(f
" {case_path}: {path} must be at least {minimum}.")
8576 if exclusive_minimum
is not None and value <= exclusive_minimum:
8577 errors.append(f
" {case_path}: {path} must be greater than {exclusive_minimum}.")
8580 if 'enabled' in les_cfg
and not isinstance(les_cfg[
'enabled'], bool):
8581 errors.append(f
" {case_path}: models.physics.turbulence.les.enabled must be true or false.")
8583 _numeric(les_cfg,
'constant_cs',
"models.physics.turbulence.les.constant_cs", minimum=0.0)
8584 _numeric(les_cfg,
'vreman_coefficient',
"models.physics.turbulence.les.vreman_coefficient", minimum=0.0)
8585 _numeric(les_cfg,
'wale_coefficient',
"models.physics.turbulence.les.wale_coefficient", minimum=0.0)
8587 if 'dynamic_frequency' in les_cfg:
8589 if int(les_cfg[
'dynamic_frequency']) <= 0:
8590 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
8591 except (TypeError, ValueError):
8592 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
8594 for key, normalizer
in ((
'filter_width', normalize_les_filter_width),):
8597 normalizer(les_cfg[key])
8598 except ValueError
as exc:
8599 errors.append(f
" {case_path}: {exc}")
8603 test_filter = les_cfg.get(
'test_filter')
8604 if test_filter
is not None:
8605 if not isinstance(test_filter, dict):
8606 test_filter = {
'kernel': test_filter}
8607 if 'kernel' in test_filter:
8610 except ValueError
as exc:
8611 errors.append(f
" {case_path}: {exc}")
8615 if kernel == 1
and not {
"i",
"k"} <= periodic:
8617 f
" {case_path}: models.physics.turbulence.les.test_filter.kernel "
8618 "'simpson_ik' assumes the xi and zeta directions are homogeneous, but "
8619 "this case does not declare both of them PERIODIC. Use "
8620 "'volume_weighted_box' instead."
8624 _numeric(test_filter,
'width_ratio',
8625 "models.physics.turbulence.les.test_filter.width_ratio", exclusive_minimum=1.0)
8627 averaging = les_cfg.get(
'averaging')
8628 if averaging
is not None:
8629 if not isinstance(averaging, dict):
8630 averaging = {
'mode': averaging}
8632 if 'mode' in averaging:
8635 except ValueError
as exc:
8636 errors.append(f
" {case_path}: {exc}")
8638 if 'directions' in averaging:
8641 except ValueError
as exc:
8642 errors.append(f
" {case_path}: {exc}")
8646 f
" {case_path}: models.physics.turbulence.les.averaging.directions "
8647 "cannot be empty; omit the key to use the periodic axes."
8649 if mode
is not None and mode != 1:
8651 f
" {case_path}: models.physics.turbulence.les.averaging.directions "
8652 "applies only to mode 'homogeneous'; local and global averaging choose "
8653 "their own directions."
8655 for axis
in directions:
8656 if axis
not in periodic:
8658 f
"{case_path}: models.physics.turbulence.les.averaging.directions "
8659 f
"names '{axis}', which this case does not declare PERIODIC. "
8660 "Averaging assumes the flow is statistically homogeneous there."
8662 if mode == 1
and directions
is None and not periodic:
8664 f
" {case_path}: models.physics.turbulence.les.averaging.mode 'homogeneous' "
8665 "derives its directions from the periodic boundary pairs, and this case declares "
8666 "none. Name the directions explicitly or use 'local'."
8669 clipping = les_cfg.get(
'clipping')
8670 if clipping
is not None:
8671 if not isinstance(clipping, dict):
8672 clipping = {
'mode': clipping}
8674 if 'mode' in clipping:
8677 except ValueError
as exc:
8678 errors.append(f
" {case_path}: {exc}")
8679 _numeric(clipping,
'max_cs',
"models.physics.turbulence.les.clipping.max_cs", minimum=0.0)
8680 _numeric(clipping,
'min_viscosity_ratio',
8681 "models.physics.turbulence.les.clipping.min_viscosity_ratio", minimum=0.0)
8682 if 'max_cs' in clipping
and mode
is not None and mode != 0:
8684 f
" {case_path}: models.physics.turbulence.les.clipping.max_cs applies only to "
8685 "mode 'clamp'; 'positive' and 'signed' impose no upper bound. Remove the key or "
8686 "select mode 'clamp'."
8696 if model_code
in LES_MODEL_OWNED_KEYS
and les_cfg.get(
'enabled',
True):
8701 model_name = LES_MODEL_NAMES[model_code]
8702 for owner, keys
in LES_MODEL_OWNED_KEYS.items():
8703 if owner == model_code:
8708 f
" {case_path}: models.physics.turbulence.les.{key} configures "
8709 f
"{LES_MODEL_KEY_OWNER_DESCRIPTIONS[owner]} and cannot be used with "
8710 f
"model '{model_name}'."
8712 if model_code == 3
and 'filter_width' in les_cfg:
8714 f
" {case_path}: models.physics.turbulence.les.filter_width cannot be used with "
8715 "model 'vreman', which weights each grid direction by the cell's own edge "
8716 "rather than by one scalar width."
8722 @brief Validate BC entries against currently supported C-side handlers/types and
8723 @details return normalized entries ready for bcs.run generation.
8724 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
8725 @return Value returned by `validate_and_prepare_boundary_conditions()`.
8727 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
8731 prepared_blocks = []
8733 expected_faces = {
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta"}
8734 axis_pairs = [(
"-Xi",
"+Xi"), (
"-Eta",
"+Eta"), (
"-Zeta",
"+Zeta")]
8736 for bi, block_bcs
in enumerate(all_blocks_bcs):
8737 if not isinstance(block_bcs, list):
8738 raise ValueError(f
"boundary_conditions[{bi}] must be a list of face configs.")
8743 for idx, bc
in enumerate(block_bcs):
8744 if not isinstance(bc, dict):
8745 raise ValueError(f
"boundary_conditions[{bi}][{idx}] must be a mapping.")
8747 for req
in (
"face",
"type",
"handler"):
8749 raise ValueError(f
"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
8751 face_raw = str(bc[
"face"]).strip()
8752 face_key = face_raw.lower()
8753 face = BC_FACE_MAP.get(face_key)
8756 f
"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
8757 f
"Supported: {sorted(expected_faces)}."
8759 if face
in seen_faces:
8760 raise ValueError(f
"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
8761 seen_faces[face] = idx
8763 bc_type_raw = str(bc[
"type"]).strip()
8764 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
8766 if bc_type_raw.lower() ==
"symmetry":
8768 f
"BC type 'symmetry' for face {face} in block {bi} is retired: a symmetry plane "
8769 "is a free-slip wall, so use type 'wall' with handler 'free_slip'."
8773 f
"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
8774 f
"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
8777 handler = str(bc[
"handler"]).strip().lower()
8778 handler_spec = BC_HANDLER_SPECS.get(handler)
8779 if handler_spec
is None:
8781 f
"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
8782 f
"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
8784 if bc_type
not in handler_spec[
"types"]:
8786 f
"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
8789 params = bc.get(
"params", {})
8792 if not isinstance(params, dict):
8793 raise ValueError(f
"'params' for block {bi}, face {face} must be a mapping.")
8796 if "vector" in params
or "velocity" in params:
8798 f
"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
8799 "Use scalar keys 'vx', 'vy', 'vz'."
8802 required = handler_spec[
"required_params"]
8803 optional = handler_spec[
"optional_params"]
8804 allowed = required | optional
8806 missing = sorted(required - set(params.keys()))
8809 f
"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
8811 unknown = sorted(set(params.keys()) - allowed)
8814 f
"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
8815 f
"Allowed: {sorted(allowed)}."
8818 converted_params = {}
8819 for key, value
in params.items():
8820 if key
in _NUMERIC_BC_PARAMS:
8822 value, BC_PARAM_QUANTITIES[key], scales,
8823 f
"boundary_conditions[{bi}][{idx}].params.{key}")
8824 elif key
in _BOOL_BC_PARAMS:
8825 canonical = _DEPRECATED_BC_PARAM_ALIASES.get(key, key)
8826 if canonical != key:
8827 if canonical
in params:
8829 f
"boundary_conditions[{bi}][{idx}].params sets both '{key}' and its "
8830 f
"replacement '{canonical}'. Use '{canonical}' only."
8833 f
"[WARNING] boundary_conditions[{bi}][{idx}].params.{key} is deprecated; "
8834 f
"use '{canonical}'. It enables the local seam-flux correction on the "
8835 "periodic boundary plane, on top of the body force that sustains the bulk "
8836 "flow. See docs/pages/54_Geometric_Periodic_Boundaries.md.",
8839 converted_params[canonical] =
_to_bool(
8840 value, f
"boundary_conditions[{bi}][{idx}].params.{key}")
8841 elif handler ==
"prescribed_flow" and key ==
"source":
8843 value, f
"boundary_conditions[{bi}][{idx}].params.source"
8847 converted_params[key] = value
8849 policy = str(bc.get(
"particle_policy", handler_spec[
"particle_policy"])).strip().lower()
8850 if policy
not in PARTICLE_BOUNDARY_POLICIES:
8852 f
"Unsupported particle_policy '{bc.get('particle_policy')}' for face {face} in block {bi}. "
8853 f
"Supported: {list(PARTICLE_BOUNDARY_POLICIES)}."
8855 if (policy ==
"wrap") != (bc_type ==
"PERIODIC"):
8857 f
"boundary_conditions[{bi}][{idx}].particle_policy '{policy}' does not fit a {bc_type} face: "
8858 "PERIODIC faces wrap particles to the paired face, and only they can."
8860 converted_params[
"particle_policy"] = policy
8862 prepared_block.append({
8866 "params": converted_params,
8869 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
8872 f
"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
8873 "Provide all six faces explicitly."
8877 face_map = {entry[
"face"]: entry
for entry
in prepared_block}
8878 for neg_face, pos_face
in axis_pairs:
8879 neg = face_map[neg_face]
8880 pos = face_map[pos_face]
8881 neg_periodic = (neg[
"type"] ==
"PERIODIC")
8882 pos_periodic = (pos[
"type"] ==
"PERIODIC")
8883 if neg_periodic != pos_periodic:
8885 f
"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
8888 driven_handlers = {
"constant_flux",
"initial_flux"}
8889 if (neg[
"handler"]
in driven_handlers)
or (pos[
"handler"]
in driven_handlers):
8890 if neg[
"handler"] != pos[
"handler"]:
8892 f
"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
8894 if not (neg_periodic
and pos_periodic):
8896 f
"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
8899 prepared_blocks.append(prepared_block)
8901 return prepared_blocks
8906 @brief Render an internal schema path tuple as a user-facing YAML path.
8907 @param[in] path Internal path tuple.
8908 @return Dotted YAML path.
8910 return ".".join(part
for part
in path
if part !=
"[]")
or "<root>"
8915 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
8916 @param[in] schema Role schema mapping.
8917 @param[in] path Internal path tuple.
8918 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
8922 for idx, part
in enumerate(path):
8925 candidate = path[:idx] + (
"*",) + path[idx + 1:]
8926 if candidate
in schema:
8927 return schema[candidate]
8933 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
8934 @param[in] schema Role schema mapping.
8935 @param[in] path Current internal YAML path tuple.
8936 @param[in] key Unsupported YAML key.
8937 @param[in] allowed Allowed keys at the current path.
8938 @return Optional hint string.
8941 allowed_strings = sorted(str(item)
for item
in allowed)
8942 lower_matches = [item
for item
in allowed_strings
if item.lower() == key.lower()]
8943 close_matches = lower_matches
or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
8945 hints.append(f
"Did you mean '{close_matches[0]}'?")
8948 for schema_path, schema_allowed
in schema.items():
8949 if schema_path == path
or not schema_allowed:
8951 if key
in schema_allowed:
8954 hints.append(f
"This key is valid at: {', '.join(sorted(valid_paths))}.")
8956 return " ".join(hints)
8961 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
8962 @param[in] cfg Parsed YAML node.
8963 @param[in] schema Role schema mapping.
8964 @param[in] file_path Source file path for diagnostics.
8965 @param[in,out] errors Validation error accumulator.
8966 @param[in] path Current internal YAML path tuple.
8968 if isinstance(cfg, dict):
8970 if allowed
is not False and allowed
is not None:
8971 unknown = sorted(str(key)
for key
in cfg.keys()
if key
not in allowed)
8974 hint_text = f
" {hint}" if hint
else ""
8976 f
" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
8977 f
"Allowed keys: {sorted(allowed)}.{hint_text}"
8981 for key, value
in cfg.items():
8986 elif (isinstance(cfg, (str, int, float, list))
and not isinstance(cfg, bool)
8993 f
" {file_path}: '{_schema_path_text(path)}' must be a mapping, not "
8994 f
"{type(cfg).__name__} {cfg!r}."
8996 elif isinstance(cfg, list):
9003 "title",
"properties",
"run_control",
"grid",
"models",
"boundary_conditions",
"solver_parameters",
9005 (
"run_control",): {
"start_step",
"total_steps",
"dt_physical"},
9006 (
"properties",): {
"scaling",
"fluid",
"initial_conditions"},
9007 (
"properties",
"scaling"): {
"length_ref",
"velocity_ref"},
9008 (
"properties",
"fluid"): {
"density",
"viscosity",
"schmidt_number"},
9009 (
"properties",
"initial_conditions"): {
9010 "mode",
"generator",
"params",
"field",
"source_file",
9011 "source_case",
"velocity_scale",
"length_scale",
9012 "u_physical",
"v_physical",
"w_physical",
"peak_velocity_physical",
9013 "velocity_physical",
"flow_direction",
9015 (
"properties",
"initial_conditions",
"params"):
None,
9017 "mode",
"source_file",
"programmatic_settings",
"generator",
9018 "da_processors_x",
"da_processors_y",
"da_processors_z",
9020 (
"grid",
"programmatic_settings"): {
9021 "im",
"jm",
"km",
"xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
9022 "rxs",
"rys",
"rzs",
"cgrids",
9023 "da_processors_x",
"da_processors_y",
"da_processors_z",
9025 (
"grid",
"generator"): {
9026 "script",
"config_file",
"grid_type",
"cli_args",
"output_file",
"stats_file",
"vts_file",
9028 "config-file",
"grid-type",
"output-file",
"stats-file",
"vts-file",
9030 (
"models",): {
"domain",
"physics"},
9031 (
"models",
"domain"): {
"blocks"},
9032 (
"models",
"physics"): {
"dimensionality",
"fsi",
"particles",
"turbulence"},
9033 (
"models",
"physics",
"fsi"): {
"immersed",
"moving_fsi"},
9034 (
"models",
"physics",
"particles"): {
"count",
"init_mode",
"restart_mode",
"point_source",
"random_seed",
9035 "fields",
"iem_constant"},
9036 (
"models",
"physics",
"particles",
"fields"):
None,
9037 (
"models",
"physics",
"particles",
"point_source"): {
"x",
"y",
"z"},
9040 (
"models",
"physics",
"turbulence"): {
"les",
"rans",
"wall_function",
"turbulent_schmidt_number"},
9041 (
"models",
"physics",
"turbulence",
"les"): {
9042 "enabled",
"model",
"constant_cs",
"vreman_coefficient",
"wale_coefficient",
9043 "dynamic_frequency",
"filter_width",
9044 "test_filter",
"averaging",
"clipping",
"gradient_model",
"diagnostics",
9046 (
"models",
"physics",
"turbulence",
"les",
"test_filter"): {
"kernel",
"width_ratio"},
9047 (
"models",
"physics",
"turbulence",
"les",
"averaging"): {
"mode",
"directions"},
9048 (
"models",
"physics",
"turbulence",
"les",
"clipping"): {
9049 "mode",
"max_cs",
"min_viscosity_ratio",
9051 (
"models",
"physics",
"turbulence",
"les",
"gradient_model"): {
"enabled"},
9052 (
"models",
"physics",
"turbulence",
"les",
"diagnostics"): {
9053 "enabled",
"cadence",
"yoshizawa_ci",
9055 (
"models",
"physics",
"turbulence",
"rans"): {
"enabled",
"model"},
9056 (
"models",
"physics",
"turbulence",
"wall_function"): {
"enabled",
"model",
"roughness_height"},
9057 (
"boundary_conditions",
"[]"): {
"face",
"type",
"handler",
"params",
"particle_policy"},
9058 (
"boundary_conditions",
"[]",
"[]"): {
"face",
"type",
"handler",
"params",
"particle_policy"},
9059 (
"boundary_conditions",
"[]",
"params"):
None,
9060 (
"boundary_conditions",
"[]",
"[]",
"params"):
None,
9061 (
"solver_parameters",):
None,
9067 "operation_mode",
"strategy",
"tolerances",
"momentum_solver",
"poisson_solver",
9068 "pressure_solver",
"interpolation",
"petsc_passthrough_options",
"verification",
9071 (
"operation_mode",): {
"eulerian_field_source",
"analytical_type",
"uniform_flow"},
9072 (
"operation_mode",
"uniform_flow"): {
"u",
"v",
"w"},
9073 (
"strategy",): {
"momentum_solver",
"central_diff"},
9075 "max_iterations",
"absolute_tol",
"relative_tol",
"step_tol",
9076 "residual_absolute_tol",
"residual_relative_tol",
9078 (
"momentum_solver",): {
9079 "type",
"dual_time_picard_jameson_rk",
"dual_time_picard_rk4",
"newton_krylov",
9081 (
"momentum_solver",
"dual_time_picard_jameson_rk"): {
9082 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
9083 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
9086 (
"momentum_solver",
"dual_time_picard_jameson_rk",
"pseudo_cfl"): {
9087 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
9089 (
"momentum_solver",
"dual_time_picard_rk4"): {
9090 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
9091 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
9094 (
"momentum_solver",
"dual_time_picard_rk4",
"pseudo_cfl"): {
9095 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
9097 (
"momentum_solver",
"newton_krylov"): {
9098 "jacobian",
"preconditioner",
"nonlinear_solver",
"linear_solver",
9100 (
"momentum_solver",
"newton_krylov",
"jacobian"): {
"type",
"finite_difference"},
9101 (
"momentum_solver",
"newton_krylov",
"jacobian",
"finite_difference"): {
"mode"},
9102 (
"momentum_solver",
"newton_krylov",
"preconditioner"): {
"model",
"structure"},
9103 (
"momentum_solver",
"newton_krylov",
"preconditioner",
"structure"): {
"type"},
9104 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver"): {
9105 "method",
"absolute_tolerance",
"relative_tolerance",
"step_tolerance",
9106 "max_iterations",
"line_search",
"eisenstat_walker",
9108 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver",
"line_search"): {
"type"},
9109 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver",
"eisenstat_walker"): {
9110 "enabled",
"version",
"initial_relative_tolerance",
"maximum_relative_tolerance",
9111 "gamma",
"exponent",
"safeguard_exponent",
"safeguard_threshold",
9113 (
"momentum_solver",
"newton_krylov",
"linear_solver"): {
9114 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
9115 "gmres",
"preconditioner",
9117 (
"momentum_solver",
"newton_krylov",
"linear_solver",
"gmres"): {
"restart"},
9118 (
"momentum_solver",
"newton_krylov",
"linear_solver",
"preconditioner"): {
"type"},
9119 (
"poisson_solver",): {
9120 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
9121 "gmres",
"preconditioner",
"multigrid",
9123 (
"pressure_solver",): {
9124 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
9125 "gmres",
"preconditioner",
"multigrid",
9127 (
"poisson_solver",
"gmres"): {
"restart"},
9128 (
"pressure_solver",
"gmres"): {
"restart"},
9129 (
"poisson_solver",
"preconditioner"): {
"type"},
9130 (
"pressure_solver",
"preconditioner"): {
"type"},
9131 (
"poisson_solver",
"multigrid"): {
9132 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
9134 (
"pressure_solver",
"multigrid"): {
9135 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
9137 (
"poisson_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
9138 (
"pressure_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
9139 (
"poisson_solver",
"multigrid",
"level_solvers",
"*"): {
9140 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
9142 (
"pressure_solver",
"multigrid",
"level_solvers",
"*"): {
9143 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
9145 (
"interpolation",): {
"method"},
9146 (
"petsc_passthrough_options",):
None,
9147 (
"verification",): {
"sources"},
9148 (
"verification",
"sources"): {
"diffusivity",
"scalar"},
9149 (
"verification",
"sources",
"diffusivity"): {
"mode",
"profile",
"gamma0",
"slope_x"},
9150 (
"verification",
"sources",
"scalar"): {
9151 "mode",
"profile",
"value",
"phi0",
"slope_x",
"amplitude",
"kx",
"ky",
"kz",
9153 (
"scalar_transport",): set(SCALAR_TRANSPORT_FLAGS),
9159 "logging",
"profiling",
"diagnostics",
"io",
"solver_monitoring",
"solution_monitoring",
9162 (
"logging",): {
"verbosity",
"enabled_functions",
"trace_particles"},
9163 (
"profiling",): {
"timestep_output",
"final_summary",
"helpers"},
9164 (
"profiling",
"timestep_output"): {
"mode",
"functions"},
9165 (
"profiling",
"final_summary"): {
"enabled"},
9166 (
"diagnostics",): {
"petsc",
"runtime_memory_log",
"les"},
9167 (
"diagnostics",
"les"): {
"enabled",
"cadence",
"yoshizawa_ci"},
9168 (
"diagnostics",
"petsc"): {
9169 "info",
"malloc_debug",
"malloc_test",
"malloc_dump",
"malloc_view",
"malloc_view_threshold",
9170 "memory_view",
"log_view",
"log_view_memory",
"log_all",
"log_trace",
9171 "objects_dump",
"options_left",
9173 (
"diagnostics",
"petsc",
"info"): {
"enabled",
"classes"},
9174 (
"diagnostics",
"runtime_memory_log"): {
"enabled"},
9176 "data_output_frequency",
"particle_console_output_frequency",
"particle_log_interval",
9177 "statistics_console_output_frequency",
"write_initial_checkpoint",
"write_final_checkpoint",
9179 (
"solver_monitoring",): {
"momentum",
"poisson",
"petsc_passthrough_options"},
9180 (
"solver_monitoring",
"momentum"): {
9181 "newton_krylov_history",
"snes_monitor",
"snes_converged_reason",
9182 "ksp_monitor",
"ksp_converged_reason",
9184 (
"solver_monitoring",
"poisson"): {
"pic_true_residual",
"true_residual",
"converged_reason",
"view"},
9185 (
"solver_monitoring",
"petsc_passthrough_options"):
None,
9186 (
"solution_monitoring",): {
"convergence"},
9187 (
"solution_monitoring",
"convergence"): {
9188 "enabled",
"mode",
"periodic_deterministic",
"statistical_steady",
9190 (
"solution_monitoring",
"convergence",
"periodic_deterministic"): {
"period_steps"},
9191 (
"solution_monitoring",
"convergence",
"statistical_steady"): {
"window_steps"},
9192 (
"field_statistics",): {
"enabled",
"windows"},
9193 (
"field_statistics",
"windows",
"[]"): {
9194 "name",
"start_time",
"end_time",
"weighting",
9195 "step_cadence",
"time_cadence",
"fields",
"covariances",
9197 (
"field_statistics",
"windows",
"[]",
"fields",
"[]"): {
"field",
"moments"},
9208STATISTICS_ELIGIBLE_FIELDS = {
9209 "Ucat": {
"components": 3,
"requires":
None},
9210 "P": {
"components": 1,
"requires":
None},
9211 "Nvert": {
"components": 1,
"requires":
None},
9212 "Phi": {
"components": 1,
"requires":
None},
9213 "Psi": {
"components": 1,
"requires":
"particles"},
9214 "ParticleCount": {
"components": 1,
"requires":
"particles"},
9215 "Nu_t": {
"components": 1,
"requires":
"turbulence"},
9216 "CS": {
"components": 1,
"requires":
"les"},
9221STATISTICS_PARTICLE_FIELDS = {
9222 "Psi": {
"components": 1,
"requires":
"particles",
"target":
"Psi"},
9223 "velocity": {
"components": 3,
"requires":
"particles",
"target":
"Ucat"},
9228POST_STATISTICS_ENSEMBLE_SOURCES = (
"particles",
"eulerian")
9233 @brief Return the field table an ensemble of one source names its fields from.
9234 @param[in] source One of `POST_STATISTICS_ENSEMBLE_SOURCES`.
9235 @return The particle or the Eulerian statistics field table.
9237 return STATISTICS_PARTICLE_FIELDS
if source ==
"particles" else STATISTICS_ELIGIBLE_FIELDS
9243STATISTICS_MOMENT_NAMES = (
"first",
"second")
9246STATISTICS_WEIGHTING_MODES = (
"sample",
"physical_time")
9251 "run_control",
"source_data",
"global_operations",
"eulerian_pipeline",
9252 "lagrangian_pipeline",
"statistics_pipeline",
"statistics_output_prefix",
9253 "field_statistics",
"spectra",
"reducers",
"io",
9255 (
"field_statistics",): {
"windows",
"ensembles",
"homogeneous",
"source_step",
"outputs",
"formats"},
9256 (
"field_statistics",
"ensembles",
"[]"): {
"name",
"source",
"fields",
"covariances"},
9257 (
"field_statistics",
"ensembles",
"[]",
"fields",
"[]"): {
"field",
"moments"},
9258 (
"spectra",): {
"output_prefix",
"tasks"},
9259 (
"reducers",): {
"tasks"},
9260 (
"reducers",
"tasks",
"[]"): {
9261 "task",
"source",
"wall_axis",
"stream_axis",
"friction_velocity",
"value",
"fold",
"half_height",
9263 (
"spectra",
"tasks",
"[]"): {
9264 "task",
"field",
"block",
"symbol",
"subtract_mean",
"mean_source_step",
9265 "reference",
"axes",
"fixed_indices",
9268 "start_step",
"end_step",
"step_interval",
"startTime",
"endTime",
"timeStep",
9270 (
"source_data",): {
"directory",
"input_extensions"},
9271 (
"source_data",
"input_extensions"): {
"eulerian",
"particle"},
9272 (
"global_operations",): {
"dimensionalize"},
9273 (
"eulerian_pipeline",
"[]"): {
"task",
"input_field",
"output_field",
"field",
"reference_point"},
9274 (
"lagrangian_pipeline",
"[]"): {
"task",
"input_field",
"output_field"},
9275 (
"statistics_pipeline",): {
"output_prefix",
"tasks"},
9276 (
"statistics_pipeline",
"tasks",
"[]"): {
"task"},
9278 "output_directory",
"output_filename_prefix",
"particle_filename_prefix",
"output_particles",
9279 "particle_subsampling_frequency",
"input_extensions",
9280 "eulerian_fields",
"particle_fields",
"paraview_series",
9282 (
"io",
"input_extensions"): {
"eulerian",
"particle"},
9283 (
"io",
"paraview_series"): {
"enabled",
"scope"},
9288 (): {
"scheduler",
"resources",
"notifications",
"execution"},
9289 (
"scheduler",): {
"type"},
9290 (
"resources",): {
"account",
"partition",
"nodes",
"ntasks_per_node",
"mem",
"time"},
9291 (
"notifications",): {
"mail_user",
"mail_type"},
9293 "module_setup",
"launcher",
"launcher_args",
"extra_sbatch",
"walltime_guard",
"decomposition",
9295 (
"execution",
"decomposition"): {
"i",
"j",
"k"},
9296 (
"execution",
"extra_sbatch"):
None,
9297 (
"execution",
"walltime_guard"): {
9298 "enabled",
"warmup_steps",
"multiplier",
"min_seconds",
"estimator_alpha",
9305 "title",
"base_configs",
"study_type",
"parameters",
"parameter_sets",
"metrics",
"plotting",
"execution",
9307 (
"base_configs",): {
"case",
"solver",
"monitor",
"post"},
9308 (
"parameters",):
None,
9309 (
"parameter_sets",
"[]"):
None,
9310 (
"metrics",
"[]"): {
9311 "name",
"source",
"file_glob",
"column",
"regex",
"reduction",
"normalize_by_parameter",
9312 "numerator_column",
"denominator_column",
"denominator_floor",
9313 "plot_label",
"label",
"units",
9315 (
"plotting",): {
"enabled",
"output_format"},
9316 (
"execution",): {
"max_concurrent_array_tasks"},
9320_WORKSPACE_SCHEMA = {
9321 (): {
"schema_version",
"workspace",
"software",
"paths",
"reproducibility"},
9322 (
"workspace",): {
"id",
"template",
"created_at"},
9323 (
"software",): {
"picurv"},
9324 (
"paths",): {
"config",
"inputs",
"assets",
"runs",
"studies"},
9325 (
"reproducibility",): {
"require_clean_release",
"pin_executables"},
9338DIMENSIONLESS = (0, 0, 0)
9342WAVENUMBER = (-1, 0, 0)
9343VOLUME_FLUX = (2, 1, 0)
9344DIFFUSIVITY = (1, 1, 0)
9347DYNAMIC_VISCOSITY = (1, 1, 1)
9357INPUT_CONVERSION_SITES = (
"cli",
"c",
"staging",
"provider",
"evaluator",
"reference",
"passthrough",
"")
9359NOT_A_QUANTITY = (
None,
"")
9360UNITLESS = (DIMENSIONLESS,
"")
9372 (
"case",
"title"): NOT_A_QUANTITY,
9373 (
"case",
"run_control",
"start_step"): NOT_A_QUANTITY,
9374 (
"case",
"run_control",
"total_steps"): NOT_A_QUANTITY,
9375 (
"case",
"run_control",
"dt_physical"): (TIME,
"cli"),
9376 (
"case",
"properties",
"scaling",
"length_ref"): (LENGTH,
"reference"),
9377 (
"case",
"properties",
"scaling",
"velocity_ref"): (VELOCITY,
"reference"),
9378 (
"case",
"properties",
"fluid",
"density"): (DENSITY,
"reference"),
9379 (
"case",
"properties",
"fluid",
"viscosity"): (DYNAMIC_VISCOSITY,
"reference"),
9380 (
"case",
"properties",
"fluid",
"schmidt_number"): UNITLESS,
9381 (
"case",
"properties",
"initial_conditions",
"mode"): NOT_A_QUANTITY,
9382 (
"case",
"properties",
"initial_conditions",
"generator"): NOT_A_QUANTITY,
9383 (
"case",
"properties",
"initial_conditions",
"field"): NOT_A_QUANTITY,
9384 (
"case",
"properties",
"initial_conditions",
"flow_direction"): NOT_A_QUANTITY,
9386 (
"case",
"properties",
"initial_conditions",
"source_file"): (
None,
"staging"),
9387 (
"case",
"properties",
"initial_conditions",
"source_case"): NOT_A_QUANTITY,
9388 (
"case",
"properties",
"initial_conditions",
"velocity_scale"): (VELOCITY,
"reference"),
9389 (
"case",
"properties",
"initial_conditions",
"length_scale"): (LENGTH,
"reference"),
9390 (
"case",
"properties",
"initial_conditions",
"u_physical"): (VELOCITY,
"cli"),
9391 (
"case",
"properties",
"initial_conditions",
"v_physical"): (VELOCITY,
"cli"),
9392 (
"case",
"properties",
"initial_conditions",
"w_physical"): (VELOCITY,
"cli"),
9393 (
"case",
"properties",
"initial_conditions",
"velocity_physical"): (VELOCITY,
"cli"),
9394 (
"case",
"properties",
"initial_conditions",
"peak_velocity_physical"): (VELOCITY,
"cli"),
9395 (
"case",
"grid",
"mode"): NOT_A_QUANTITY,
9396 (
"case",
"grid",
"source_file"): (LENGTH,
"staging"),
9397 (
"case",
"grid",
"da_processors_x"): NOT_A_QUANTITY,
9398 (
"case",
"grid",
"da_processors_y"): NOT_A_QUANTITY,
9399 (
"case",
"grid",
"da_processors_z"): NOT_A_QUANTITY,
9400 (
"case",
"grid",
"programmatic_settings",
"im"): NOT_A_QUANTITY,
9401 (
"case",
"grid",
"programmatic_settings",
"jm"): NOT_A_QUANTITY,
9402 (
"case",
"grid",
"programmatic_settings",
"km"): NOT_A_QUANTITY,
9403 (
"case",
"grid",
"programmatic_settings",
"cgrids"): NOT_A_QUANTITY,
9404 (
"case",
"grid",
"programmatic_settings",
"da_processors_x"): NOT_A_QUANTITY,
9405 (
"case",
"grid",
"programmatic_settings",
"da_processors_y"): NOT_A_QUANTITY,
9406 (
"case",
"grid",
"programmatic_settings",
"da_processors_z"): NOT_A_QUANTITY,
9407 (
"case",
"grid",
"programmatic_settings",
"rxs"): UNITLESS,
9408 (
"case",
"grid",
"programmatic_settings",
"rys"): UNITLESS,
9409 (
"case",
"grid",
"programmatic_settings",
"rzs"): UNITLESS,
9410 (
"case",
"grid",
"programmatic_settings",
"xMins"): (LENGTH,
"c"),
9411 (
"case",
"grid",
"programmatic_settings",
"xMaxs"): (LENGTH,
"c"),
9412 (
"case",
"grid",
"programmatic_settings",
"yMins"): (LENGTH,
"c"),
9413 (
"case",
"grid",
"programmatic_settings",
"yMaxs"): (LENGTH,
"c"),
9414 (
"case",
"grid",
"programmatic_settings",
"zMins"): (LENGTH,
"c"),
9415 (
"case",
"grid",
"programmatic_settings",
"zMaxs"): (LENGTH,
"c"),
9417 (
"case",
"grid",
"generator"): (LENGTH,
"staging"),
9418 (
"case",
"models",
"domain",
"blocks"): NOT_A_QUANTITY,
9419 (
"case",
"models",
"physics",
"dimensionality"): NOT_A_QUANTITY,
9420 (
"case",
"models",
"physics",
"fsi"): NOT_A_QUANTITY,
9421 (
"case",
"models",
"physics",
"particles",
"count"): NOT_A_QUANTITY,
9422 (
"case",
"models",
"physics",
"particles",
"init_mode"): NOT_A_QUANTITY,
9423 (
"case",
"models",
"physics",
"particles",
"restart_mode"): NOT_A_QUANTITY,
9424 (
"case",
"models",
"physics",
"particles",
"random_seed"): NOT_A_QUANTITY,
9425 (
"case",
"models",
"physics",
"particles",
"iem_constant"): UNITLESS,
9426 (
"case",
"models",
"physics",
"particles",
"point_source",
"x"): (LENGTH,
"cli"),
9427 (
"case",
"models",
"physics",
"particles",
"point_source",
"y"): (LENGTH,
"cli"),
9428 (
"case",
"models",
"physics",
"particles",
"point_source",
"z"): (LENGTH,
"cli"),
9430 (
"case",
"models",
"physics",
"particles",
"fields"): (
None,
"evaluator"),
9432 (
"case",
"models",
"physics",
"turbulence",
"les"): UNITLESS,
9433 (
"case",
"models",
"physics",
"turbulence",
"rans"): NOT_A_QUANTITY,
9434 (
"case",
"models",
"physics",
"turbulence",
"turbulent_schmidt_number"): UNITLESS,
9435 (
"case",
"models",
"physics",
"turbulence",
"wall_function",
"enabled"): NOT_A_QUANTITY,
9436 (
"case",
"models",
"physics",
"turbulence",
"wall_function",
"model"): NOT_A_QUANTITY,
9437 (
"case",
"models",
"physics",
"turbulence",
"wall_function",
"roughness_height"): (LENGTH,
"cli"),
9438 (
"case",
"boundary_conditions",
"[]",
"face"): NOT_A_QUANTITY,
9439 (
"case",
"boundary_conditions",
"[]",
"type"): NOT_A_QUANTITY,
9440 (
"case",
"boundary_conditions",
"[]",
"handler"): NOT_A_QUANTITY,
9441 (
"case",
"boundary_conditions",
"[]",
"particle_policy"): NOT_A_QUANTITY,
9442 (
"case",
"boundary_conditions",
"[]",
"[]",
"face"): NOT_A_QUANTITY,
9443 (
"case",
"boundary_conditions",
"[]",
"[]",
"type"): NOT_A_QUANTITY,
9444 (
"case",
"boundary_conditions",
"[]",
"[]",
"handler"): NOT_A_QUANTITY,
9445 (
"case",
"boundary_conditions",
"[]",
"[]",
"particle_policy"): NOT_A_QUANTITY,
9446 (
"case",
"solver_parameters"): (
None,
"passthrough"),
9448 (
"solver",
"operation_mode",
"eulerian_field_source"): NOT_A_QUANTITY,
9449 (
"solver",
"operation_mode",
"analytical_type"): NOT_A_QUANTITY,
9450 (
"solver",
"operation_mode",
"uniform_flow",
"u"): (VELOCITY,
"cli"),
9451 (
"solver",
"operation_mode",
"uniform_flow",
"v"): (VELOCITY,
"cli"),
9452 (
"solver",
"operation_mode",
"uniform_flow",
"w"): (VELOCITY,
"cli"),
9454 (
"solver",
"strategy"): NOT_A_QUANTITY,
9455 (
"solver",
"tolerances"): UNITLESS,
9456 (
"solver",
"momentum_solver"): UNITLESS,
9457 (
"solver",
"poisson_solver"): UNITLESS,
9458 (
"solver",
"pressure_solver"): UNITLESS,
9459 (
"solver",
"interpolation"): NOT_A_QUANTITY,
9460 (
"solver",
"petsc_passthrough_options"): (
None,
"passthrough"),
9461 (
"solver",
"verification",
"sources",
"diffusivity",
"mode"): NOT_A_QUANTITY,
9462 (
"solver",
"verification",
"sources",
"diffusivity",
"profile"): NOT_A_QUANTITY,
9463 (
"solver",
"verification",
"sources",
"diffusivity",
"gamma0"): (DIFFUSIVITY,
"cli"),
9465 (
"solver",
"verification",
"sources",
"diffusivity",
"slope_x"): (VELOCITY,
"cli"),
9466 (
"solver",
"verification",
"sources",
"scalar",
"mode"): NOT_A_QUANTITY,
9467 (
"solver",
"verification",
"sources",
"scalar",
"profile"): NOT_A_QUANTITY,
9468 (
"solver",
"verification",
"sources",
"scalar",
"value"): UNITLESS,
9469 (
"solver",
"verification",
"sources",
"scalar",
"phi0"): UNITLESS,
9470 (
"solver",
"verification",
"sources",
"scalar",
"amplitude"): UNITLESS,
9471 (
"solver",
"verification",
"sources",
"scalar",
"slope_x"): (WAVENUMBER,
"cli"),
9472 (
"solver",
"verification",
"sources",
"scalar",
"kx"): (WAVENUMBER,
"cli"),
9473 (
"solver",
"verification",
"sources",
"scalar",
"ky"): (WAVENUMBER,
"cli"),
9474 (
"solver",
"verification",
"sources",
"scalar",
"kz"): (WAVENUMBER,
"cli"),
9475 (
"solver",
"scalar_transport"): UNITLESS,
9477 (
"monitor",
"logging"): NOT_A_QUANTITY,
9478 (
"monitor",
"profiling"): NOT_A_QUANTITY,
9479 (
"monitor",
"diagnostics"): NOT_A_QUANTITY,
9480 (
"monitor",
"io"): NOT_A_QUANTITY,
9481 (
"monitor",
"solver_monitoring"): NOT_A_QUANTITY,
9482 (
"monitor",
"solution_monitoring"): NOT_A_QUANTITY,
9483 (
"monitor",
"field_statistics",
"enabled"): NOT_A_QUANTITY,
9484 (
"monitor",
"field_statistics",
"windows",
"[]",
"name"): NOT_A_QUANTITY,
9485 (
"monitor",
"field_statistics",
"windows",
"[]",
"weighting"): NOT_A_QUANTITY,
9486 (
"monitor",
"field_statistics",
"windows",
"[]",
"step_cadence"): NOT_A_QUANTITY,
9487 (
"monitor",
"field_statistics",
"windows",
"[]",
"start_time"): (TIME,
"cli"),
9488 (
"monitor",
"field_statistics",
"windows",
"[]",
"end_time"): (TIME,
"cli"),
9489 (
"monitor",
"field_statistics",
"windows",
"[]",
"time_cadence"): (TIME,
"cli"),
9490 (
"monitor",
"field_statistics",
"windows",
"[]",
"fields"): NOT_A_QUANTITY,
9491 (
"monitor",
"field_statistics",
"windows",
"[]",
"covariances"): NOT_A_QUANTITY,
9493 (
"post",): NOT_A_QUANTITY,
9497BC_PARAM_QUANTITIES = {
9498 "vx": (VELOCITY,
"cli"),
9499 "vy": (VELOCITY,
"cli"),
9500 "vz": (VELOCITY,
"cli"),
9501 "v_max": (VELOCITY,
"cli"),
9502 "target_flux": (VOLUME_FLUX,
"cli"),
9503 "enforce_seam_flux": NOT_A_QUANTITY,
9504 "apply_trim": NOT_A_QUANTITY,
9507 "source": (VELOCITY,
"staging"),
9512IC_PARAM_QUANTITIES = {
9515 "u_physical": (VELOCITY,
"cli"),
"v_physical": (VELOCITY,
"cli"),
9516 "w_physical": (VELOCITY,
"cli"),
"velocity_physical": (VELOCITY,
"cli"),
9517 "flow_direction": NOT_A_QUANTITY,
9519 "streamwise_constant": {
9520 "velocity_physical": (VELOCITY,
"cli"),
"flow_direction": NOT_A_QUANTITY,
9523 "peak_velocity_physical": (VELOCITY,
"cli"),
"flow_direction": NOT_A_QUANTITY,
9526 "field": NOT_A_QUANTITY,
"script": NOT_A_QUANTITY,
"cli_args": NOT_A_QUANTITY,
9528 "config_file": (
None,
"provider"),
9530 "spectral_random_velocity": {
9531 "field": NOT_A_QUANTITY,
"seed": NOT_A_QUANTITY,
"remove_mean": NOT_A_QUANTITY,
9532 "random.distribution": NOT_A_QUANTITY,
"random.mean": (VELOCITY,
"cli"),
9533 "spectrum.type": NOT_A_QUANTITY,
9534 "spectrum.k0": (WAVENUMBER,
"cli"),
"spectrum.k_cut": (WAVENUMBER,
"cli"),
9535 "projection.type": NOT_A_QUANTITY,
"projection.operator": NOT_A_QUANTITY,
9536 "normalization.type": NOT_A_QUANTITY,
"normalization.target": (VELOCITY,
"cli"),
9539IC_PARAM_QUANTITIES[
"resampled_velocity"] = {
9540 "field": NOT_A_QUANTITY,
"format": NOT_A_QUANTITY,
"cells": NOT_A_QUANTITY,
9541 "sample_offset": NOT_A_QUANTITY,
"projection": NOT_A_QUANTITY,
"remove_mean": NOT_A_QUANTITY,
9543 "source_file": (VELOCITY,
"provider"),
9544 "box_length": (LENGTH,
"cli"),
9545 "layout.fastest_axis": NOT_A_QUANTITY,
"layout.components": NOT_A_QUANTITY,
9546 "layout.dtype": NOT_A_QUANTITY,
"layout.byte_order": NOT_A_QUANTITY,
9547 "layout.header_bytes": NOT_A_QUANTITY,
"layout.datasets": NOT_A_QUANTITY,
9548 "filter.type": NOT_A_QUANTITY,
"filter.cutoff": (WAVENUMBER,
"cli"),
"filter.width": (LENGTH,
"cli"),
9550IC_PARAM_QUANTITIES[
"channel_spectral_velocity"] = IC_PARAM_QUANTITIES[
"duct_spectral_velocity"] = {
9551 "field": NOT_A_QUANTITY,
"seed": NOT_A_QUANTITY,
"streamwise_axis": NOT_A_QUANTITY,
9552 "wall_axes": NOT_A_QUANTITY,
"wall_modes": NOT_A_QUANTITY,
"initial_spectra": NOT_A_QUANTITY,
9553 "bulk_velocity": (VELOCITY,
"cli"),
"perturbation_rms": (VELOCITY,
"cli"),
9554 "spectrum.type": NOT_A_QUANTITY,
9555 "spectrum.k0": (WAVENUMBER,
"cli"),
"spectrum.k_cut": (WAVENUMBER,
"cli"),
9557IC_PARAM_QUANTITIES[
"stability_mode_velocity"] = {
9558 "field": NOT_A_QUANTITY,
"streamwise_axis": NOT_A_QUANTITY,
"cross_axis": NOT_A_QUANTITY,
9559 "equation": NOT_A_QUANTITY,
"eigen_points": NOT_A_QUANTITY,
"initial_spectra": NOT_A_QUANTITY,
9561 "base_profile": (VELOCITY,
"provider"),
9562 "velocity_difference": (VELOCITY,
"cli"),
9564 "modes": NOT_A_QUANTITY,
9565 "noise.rms": NOT_A_QUANTITY,
"noise.seed": NOT_A_QUANTITY,
"noise.wall_modes": NOT_A_QUANTITY,
9566 "noise.planar": NOT_A_QUANTITY,
"noise.spectrum.type": NOT_A_QUANTITY,
9567 "noise.spectrum.k0": (WAVENUMBER,
"cli"),
"noise.spectrum.k_cut": (WAVENUMBER,
"cli"),
9573 @brief Return the `(dimension, conversion site)` recorded for one configuration input.
9574 @param[in] path Role followed by the schema path, e.g. `("case", "run_control", "dt_physical")`.
9575 @return The entry for the path, or for its nearest covering ancestor.
9576 @throws KeyError when no entry covers the path.
9578 for length
in range(len(path), 0, -1):
9579 entry = INPUT_QUANTITIES.get(tuple(path[:length]))
9580 if entry
is not None:
9582 raise KeyError(f
"No physical dimension is recorded for input {'.'.join(map(str, path))}.")
9587 @brief Return the factor that turns a physical quantity of this dimension into solver units.
9588 @param[in] dimension `(length, velocity, density)` exponents.
9589 @param[in] scales Mapping with `length_ref` and `velocity_ref`, and `density` when used.
9590 @return `length_ref**a * velocity_ref**b * density**c`.
9592 length, velocity, density = dimension
9593 factor = float(scales[
"length_ref"]) ** length * float(scales[
"velocity_ref"]) ** velocity
9595 factor *= float(scales[
"density"]) ** density
9601 @brief Return the reference scale of one recorded input, for converting a whole payload.
9602 @details File payloads (grids, inlet profiles) are converted element by element by
9603 their own readers; this gives them the one factor their recorded dimension
9604 implies, so they do not restate the dimension.
9605 @param[in] path Role and schema path of the input.
9606 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9607 @return The reference scale of the input's recorded dimension.
9610 if dimension
is None:
9611 raise ValueError(f
"Input {'.'.join(map(str, path[1:]))} has no fixed dimension.")
9617 @brief Convert a physical value, or a list of them, by one recorded quantity entry.
9618 @param[in] value Physical value as written by the user.
9619 @param[in] quantity `(dimension, conversion site)` entry from a dimension table.
9620 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9621 @param[in] label Configuration path, for diagnostics.
9622 @return The value divided by its dimension's reference scale.
9623 @throws ValueError when the entry records no fixed dimension.
9625 dimension, _site = quantity
9626 if dimension
is None:
9627 raise ValueError(f
"Input {label} has no fixed dimension to convert.")
9629 if isinstance(value, (list, tuple)):
9636 @brief Convert one physical configuration value, or a list of them, to solver units.
9637 @param[in] value Physical value as written by the user.
9638 @param[in] path Role and schema path whose recorded dimension governs the conversion.
9639 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9640 @return The value divided by its dimension's reference scale.
9641 @throws ValueError when the path records no fixed dimension.
9644 ".".join(str(part)
for part
in path[1:]))
9649 @brief Resolve the reference length and velocity (and density, when given) of a case.
9650 @details This is the part of the scaling contract every conversion needs. It does not
9651 require the fluid block, so boundary-condition preparation can use it before
9652 the full fluid contract is validated.
9653 @param[in] case_cfg Parsed case configuration.
9654 @return Mapping with `length_ref`, `velocity_ref`, and optionally `density`.
9655 @throws ValueError when a reference scale is missing, non-finite, or not positive.
9657 scaling = ((case_cfg.get(
"properties")
or {}).get(
"scaling")
or {})
9658 length_ref =
_to_finite_float(scaling.get(
"length_ref"),
"properties.scaling.length_ref")
9659 velocity_ref =
_to_finite_float(scaling.get(
"velocity_ref"),
"properties.scaling.velocity_ref")
9660 if length_ref <= 0.0
or velocity_ref <= 0.0:
9661 raise ValueError(
"properties.scaling.length_ref and velocity_ref must be positive.")
9662 scales = {
"length_ref": length_ref,
"velocity_ref": velocity_ref}
9663 density = ((case_cfg.get(
"properties")
or {}).get(
"fluid")
or {}).get(
"density")
9664 if density
is not None:
9669RUN_OWNED_DIRECTORY_KEYS = (
"log",
"output")
9670UNSAFE_PATHS_OVERRIDE_KEY =
"allow_unsafe_paths"
9676RESERVED_RUN_DIRECTORY_NAMES = (
"config",
"scheduler",
"checkpoints",
"visualization")
9680RUN_DIRECTORY_DEFAULTS = {
"log":
"logs",
"output":
"output"}
9684RESERVED_DIRECTORY_FLAGS = (
9685 "-log_dir",
"-output_dir",
"-restart_dir",
"-analysis_dir",
9686 "-allow_unsafe_log_dir",
9692RESERVED_INDIRECTION_FLAGS = (
"-options_file",
"-options_file_yaml",
"-alias")
9699UNSAFE_DIRECTORY_CHARACTERS = (
'"',
"'",
"#",
"\n",
"\r",
"\t")
9704 @brief Describe why a directory value cannot be written to a PETSc options line.
9705 @param[in] value Configured directory value.
9706 @return Human-readable reason, or an empty string when the value is safe.
9708 if any(character.isspace()
for character
in value):
9709 return "contains whitespace"
9710 for character
in UNSAFE_DIRECTORY_CHARACTERS:
9711 if character
in value:
9712 label = {
'"':
"a double quote",
"'":
"a single quote",
"#":
"a comment marker"}.get(
9713 character,
"a control character"
9715 return f
"contains {label}"
9721 @brief Classify a configured run directory value.
9723 @details Containment is judged lexically against the run directory, because the run
9724 directory does not exist yet at validation time. Beyond escaping, a value
9725 is rejected when it resolves to the run root itself: the log directory is
9726 recursively deleted on a fresh solve, so `.` or `a/..` would delete the run.
9727 @param[in] value Configured directory value from monitor `io.directories`.
9728 @return One of "contained", "escaping", "tilde", "run_root", or "invalid".
9730 if not isinstance(value, str)
or not value.strip():
9732 candidate = value.strip()
9743 if candidate.startswith(
"~"):
9745 if os.path.isabs(candidate):
9747 normalized = os.path.normpath(candidate)
9748 if normalized == os.pardir
or normalized.startswith(os.pardir + os.sep):
9750 if normalized
in (
".",
""):
9757 @brief Normalized, comparable form of a contained run directory value.
9758 @param[in] value Configured directory value.
9759 @return Normalized relative path.
9761 return os.path.normpath(str(value).strip()).replace(os.sep,
"/")
9766 @brief Whether two run-relative directories are the same or nested in one another.
9767 @param[in] first Normalized directory.
9768 @param[in] second Normalized directory.
9769 @return True when one contains the other.
9773 return first.startswith(second +
"/")
or second.startswith(first +
"/")
9778 @brief Resolve the unsafe-paths override, requiring a real YAML boolean.
9780 @details A truthy string such as "false" must never enable an override that permits
9781 a destructive path. Only a genuine boolean `true` enables it; any other
9782 type is a configuration error rather than a silent interpretation.
9783 @param[in] dirs The `io.directories` mapping.
9784 @param[in] monitor_path Path to the monitor file, for error messages.
9785 @return Tuple of (enabled, errors).
9787 if UNSAFE_PATHS_OVERRIDE_KEY
not in dirs:
9789 raw = dirs[UNSAFE_PATHS_OVERRIDE_KEY]
9795 f
" {monitor_path}: 'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}' must be a YAML boolean "
9796 f
"(true or false), got {raw!r}. Quoted strings and numbers are rejected so a value like "
9797 f
"\"false\" cannot silently enable an unsafe path."
9803 @brief Apply every run-directory safety rule to a set of effective directory values.
9805 @details Single source of truth for the rules, shared by configuration validation and
9806 submission preflight so the two cannot drift apart. Callers must pass
9807 *effective* values with defaults filled in.
9809 Two classes of finding are distinguished. **Waivable** findings concern a
9810 deliberate external location; `allow_unsafe_paths` downgrades those to
9811 warnings. **Non-waivable** findings concern self-destruction or a value that
9812 cannot be written unambiguously - the run root itself, a reserved run
9813 directory, log/output overlap, and malformed characters. The override was
9814 granted for deliberate external storage, never for deleting the run's own
9815 config or emitting an ambiguous option line, so those stay errors.
9816 @param[in] values Effective mapping of directory key to configured value.
9817 @param[in] override Whether the unsafe-paths override is enabled.
9818 @param[in] explicit Keys the user actually configured; the rest are reported as defaults.
9819 @return Tuple of (errors, warnings) as bare messages without a file prefix.
9823 configured_keys = set(values)
if explicit
is None else set(explicit)
9825 def waivable(message: str) ->
None:
9827 @brief Record a finding the unsafe-paths override may downgrade.
9828 @param[in] message Finding text.
9833 f
"{message} Allowed only because '{UNSAFE_PATHS_OVERRIDE_KEY}: true' is set."
9837 f
"{message} Use a directory inside the run tree, or set "
9838 f
"'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}: true' to override deliberately."
9841 def fatal(message: str) ->
None:
9843 @brief Record a finding the override must never waive.
9844 @param[in] message Finding text.
9847 errors.append(f
"{message} This cannot be overridden.")
9849 destructive_note = (
9850 "On a fresh solve the runtime RECURSIVELY DELETES this directory before writing to it."
9853 for key
in RUN_OWNED_DIRECTORY_KEYS:
9854 if key
not in values:
9857 detail = destructive_note
if key ==
"log" else (
9858 "Run output must stay inside the run directory so it can be archived and restored."
9860 if not isinstance(raw, str)
or not raw.strip():
9861 fatal(f
"'io.directories.{key}' must be a non-empty relative path (got {raw!r}).")
9871 f
"'io.directories.{key}' = {raw!r} {charset_problem}. Run directory names must be "
9872 f
"writable to a PETSc options line without quoting; use a plain relative path such "
9873 f
"as 'logs' or 'diagnostics/run1'."
9875 if verdict ==
"tilde":
9877 f
"'io.directories.{key}' = {raw!r} starts with '~', which nothing expands. "
9878 f
"The control file is read by PETSc rather than by a shell, and the C "
9879 f
"runtime resolves a value not starting with '/' relative to the run - so "
9880 f
"this would be planned as one directory and deleted as another. Give a "
9881 f
"real absolute path if an external location is intended. This cannot be "
9885 if verdict ==
"escaping":
9886 if not (isinstance(raw, str)
and raw.strip().startswith(
"/")):
9888 f
"'io.directories.{key}' = {raw!r} escapes the run directory by relative "
9889 f
"traversal. {detail} A relative escape lands among sibling runs and study "
9890 f
"members, so it is never authorizable; give an absolute path if an external "
9891 f
"location is genuinely intended."
9897 waivable(f
"'io.directories.{key}' = {raw!r} escapes the run directory. {detail}")
9898 if verdict ==
"run_root":
9900 f
"'io.directories.{key}' = {raw!r} resolves to the run directory itself. "
9901 + (destructive_note +
" That would delete the entire run."
9902 if key ==
"log" else "Run output must live in its own subdirectory.")
9908 resolved[key] = normalized
9911 segments = [s
for s
in normalized.split(
"/")
if s
not in (
"",
".")]
9912 hit = next((s
for s
in segments
if s
in RESERVED_RUN_DIRECTORY_NAMES),
None)
9915 f
"'io.directories.{key}' = {raw!r} targets the reserved run directory "
9916 f
"'{hit}'. " + (destructive_note
if key ==
"log"
9917 else "That directory is owned by the run tree.")
9920 if "log" in resolved
and "output" in resolved
and paths_overlap(resolved[
"log"], resolved[
"output"]):
9921 log_source =
"" if "log" in configured_keys
else " (default)"
9922 out_source =
"" if "output" in configured_keys
else " (default)"
9924 f
"'io.directories.log' ({resolved['log']!r}{log_source}) and 'io.directories.output' "
9925 f
"({resolved['output']!r}{out_source}) overlap. {destructive_note} "
9926 f
"That would delete solver output."
9928 return errors, warnings
9935PHYSICAL_VERDICT_CONTAINED =
"contained"
9936PHYSICAL_VERDICT_RUN_ROOT =
"run_root"
9937PHYSICAL_VERDICT_ANCESTOR =
"ancestor"
9938PHYSICAL_VERDICT_RELATIVE_ESCAPE =
"relative_escape"
9939PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE =
"external_absolute"
9945WAIVABLE_PHYSICAL_VERDICTS = frozenset({PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE})
9950 @brief Classify where each run-owned directory physically lands.
9952 @details Lexical containment is not enough: a contained name can be a symlink to an
9953 external directory, and `PetscRMTree` follows symlinks. This resolves the
9954 real path - including any symlinked ancestor - and reports a typed verdict
9955 so the caller can apply the waiver rule structurally.
9956 @param[in] run_dir Run directory the values are relative to.
9957 @param[in] values Effective directory mapping.
9958 @return List of (key, verdict, message) for every value that is not contained.
9962 root = os.path.realpath(run_dir)
9965 for key
in RUN_OWNED_DIRECTORY_KEYS:
9966 raw = values.get(key)
9967 if not isinstance(raw, str)
or not raw.strip():
9977 absolute = text.startswith(
"/")
9978 candidate = os.path.join(run_dir, text)
9979 real = os.path.realpath(candidate)
9982 findings.append((key, PHYSICAL_VERDICT_RUN_ROOT,
9983 f
"'io.directories.{key}' = {raw!r} resolves to the run directory itself "
9984 f
"({real!r}); deleting it would destroy the run. This cannot be overridden."))
9985 elif real == os.sep
or root.startswith(real.rstrip(os.sep) + os.sep):
9986 findings.append((key, PHYSICAL_VERDICT_ANCESTOR,
9987 f
"'io.directories.{key}' = {raw!r} resolves to {real!r}, which CONTAINS the run "
9988 f
"directory {root!r}. The runtime deletes this path recursively, so it would "
9989 f
"destroy the run and everything beside it. This cannot be overridden."))
9990 elif real.startswith(root + os.sep):
9993 findings.append((key, PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE,
9994 f
"'io.directories.{key}' = {raw!r} resolves to {real!r}, which is outside the run "
9995 f
"directory {root!r}. The runtime deletes its log directory recursively."))
9997 findings.append((key, PHYSICAL_VERDICT_RELATIVE_ESCAPE,
9998 f
"'io.directories.{key}' = {raw!r} is a relative name that resolves to {real!r}, "
9999 f
"outside the run directory {root!r} - a symlink leads out of the tree. A "
10000 f
"relative escape is never authorizable; name an absolute path if an external "
10001 f
"location is genuinely intended. This cannot be overridden."))
10007 @brief Human-readable physical containment violations.
10008 @param[in] run_dir Run directory the values are relative to.
10009 @param[in] values Effective directory mapping.
10010 @return Violation lines.
10017 @brief Fill in defaults for run-owned directories that were not configured.
10019 @details An omitted key is not absent at runtime, it takes its default. Checking
10020 only explicit keys would miss `log: output`, which collides with the
10021 default output directory and would delete solver output.
10022 @param[in] configured Configured directory mapping, possibly partial.
10023 @return Effective mapping with defaults applied.
10025 effective = dict(RUN_DIRECTORY_DEFAULTS)
10026 for key
in RUN_OWNED_DIRECTORY_KEYS:
10027 if key
in configured:
10028 effective[key] = configured[key]
10034 @brief Classify legacy directory values as defense-in-depth during validation.
10035 @details The monitor schema rejects this removed surface. Keeping the stricter
10036 classifier here ensures malformed or manually constructed configurations
10037 still receive the safety findings that protect recursive log cleanup.
10038 @param[in] monitor_cfg Parsed monitor YAML dictionary.
10039 @param[in] monitor_path Path to the monitor file, for error messages.
10040 @return Tuple of (errors, warnings).
10042 io_cfg = (monitor_cfg
or {}).get(
"io")
or {}
10043 dirs = io_cfg.get(
"directories")
10044 if not isinstance(dirs, dict):
10051 override_errors + [f
" {monitor_path}: {message}" for message
in errors],
10052 [f
" {monitor_path}: {message}" for message
in warnings],
10058 @brief Reject raw PETSc passthrough options that set run-owned directories.
10060 @details Passthrough surfaces emit `{flag: value}` verbatim into the generated
10061 control file. Run-owned path flags are reserved for the fixed workspace
10062 topology and may only be emitted by the generator.
10063 @param[in] config Parsed configuration mapping to scan.
10064 @param[in] config_path Path to the file, for error messages.
10065 @param[in] label Human-readable description of the surface being scanned.
10066 @return Violation lines.
10068 violations: list = []
10070 def scan(node, trail: str) ->
None:
10072 @brief Walk the mapping looking for reserved flags used as keys.
10073 @param[in] node Current mapping, list, or scalar node.
10074 @param[in] trail Dotted path to the current node, for error messages.
10077 if isinstance(node, dict):
10078 for key, value
in node.items():
10079 token = key.strip()
if isinstance(key, str)
else key
10080 if token
in RESERVED_DIRECTORY_FLAGS:
10082 f
" {config_path}: {label} sets the reserved flag '{token}' at "
10083 f
"{trail or '<root>'}. Run directories are fixed by the workspace "
10084 "contract; raw passthrough cannot override them."
10086 elif token
in RESERVED_INDIRECTION_FLAGS:
10088 f
" {config_path}: {label} sets '{token}' at {trail or '<root>'}. PETSc "
10089 f
"evaluates that indirection itself, so its contents cannot be checked "
10090 "here and could reintroduce a run-directory flag. Remove the indirection."
10092 scan(value, f
"{trail}.{key}" if trail
else str(key))
10093 elif isinstance(node, list):
10094 for index, item
in enumerate(node):
10095 scan(item, f
"{trail}[{index}]")
10103 @brief Name the inputs whose meaning changed when every input became physical.
10104 @details These inputs were once read in solver units. At unit reference scales the
10105 two readings coincide, so a notice is only needed when the scales differ
10106 from one; it says which values are now divided by a reference scale.
10107 @param[in] case_cfg Parsed case configuration.
10108 @param[in] solver_cfg Parsed solver configuration.
10109 @param[in] monitor_cfg Parsed monitor configuration.
10110 @return Warning lines, empty when the case uses unit reference scales.
10114 except (ValueError, TypeError, AttributeError):
10116 if scales[
"length_ref"] == 1.0
and scales[
"velocity_ref"] == 1.0:
10118 physics = ((case_cfg.get(
"models")
or {}).get(
"physics")
or {})
10119 ic = ((case_cfg.get(
"properties")
or {}).get(
"initial_conditions")
or {})
10120 operation = (solver_cfg
or {}).get(
"operation_mode")
or {}
10121 windows = (((monitor_cfg
or {}).get(
"field_statistics")
or {}).get(
"windows")
or [])
10123 if isinstance((physics.get(
"particles")
or {}).get(
"point_source"), dict):
10124 changed.append(
"models.physics.particles.point_source")
10125 wall_cfg = (physics.get(
"turbulence")
or {}).get(
"wall_function")
10126 if isinstance(wall_cfg, dict)
and "roughness_height" in wall_cfg:
10127 changed.append(
"models.physics.turbulence.wall_function.roughness_height")
10128 if str(ic.get(
"mode",
"")).strip().lower() ==
"file":
10129 changed.append(
"properties.initial_conditions (mode: file payload)")
10130 if str(ic.get(
"generator",
"")).strip().lower().replace(
"-",
"_")
in _PYTHON_INITIAL_CONDITION_PROVIDERS:
10131 changed.append(f
"properties.initial_conditions.params ({ic.get('generator')})")
10132 if isinstance(operation, dict)
and operation.get(
"uniform_flow"):
10133 changed.append(
"operation_mode.uniform_flow")
10134 if ((solver_cfg
or {}).get(
"verification")
or {}).get(
"sources"):
10135 changed.append(
"verification.sources")
10136 if any(isinstance(window, dict)
and ({
"start_time",
"end_time",
"time_cadence"} & set(window))
10137 for window
in windows):
10138 changed.append(
"field_statistics.windows[].{start_time,end_time,time_cadence}")
10142 f
"Physical units: with length_ref={scales['length_ref']:g} and "
10143 f
"velocity_ref={scales['velocity_ref']:g}, these inputs are read as physical values and "
10144 f
"converted to solver units: {', '.join(changed)}. Inputs written in solver units "
10145 f
"before this rule must be converted; see docs/pages/19_Nondimensionalization.md."
10150 case_path: str, solver_path: str, monitor_path: str,
10151 restart_source_given: bool =
False):
10153 @brief Validates every configuration a simulation run consumes, before any work is done.
10154 @details Covers the three roles the solver is launched with: the case, the solver,
10155 and the monitor. Only one of those is a solver configuration, which is
10156 why this is not named for the solver; the monitor in particular carries
10157 observation and field-statistics contracts that have nothing to do with
10158 the numerical scheme.
10160 Checks required sections, required keys, physical sanity, and the
10161 cross-file combinations no single file can rule out. Post-processing
10162 configuration is validated separately by `validate_post_config()`.
10163 @param[in] case_cfg Parsed case YAML dictionary.
10164 @param[in] solver_cfg Parsed solver YAML dictionary.
10165 @param[in] monitor_cfg Parsed monitor YAML dictionary.
10166 @param[in] case_path Path to case file (for error messages).
10167 @param[in] solver_path Path to solver file (for error messages).
10168 @param[in] monitor_path Path to monitor file (for error messages).
10169 @param[in] restart_source_given Whether the invocation names a restart source
10170 (--restart-from or --continue), which silences the
10171 reminder that the case needs one.
10172 @throws SystemExit on validation failure.
10178 case_cfg, case_path,
"case solver_parameters / passthrough"))
10180 solver_cfg, solver_path,
"solver petsc_passthrough_options"))
10181 eulerian_source_mode =
"solve"
10183 legacy_models = case_cfg.get(
"models")
if isinstance(case_cfg, dict)
else None
10184 legacy_statistics = legacy_models.get(
"statistics")
if isinstance(legacy_models, dict)
else None
10185 if legacy_statistics
is not None:
10187 f
" {case_path}: 'models.statistics' was removed with the legacy averaging system. "
10188 "Use instantaneous output and offline postprocessing until the replacement "
10189 "field-statistics pipeline is available."
10191 if isinstance(solver_cfg, dict)
and "solution_convergence" in solver_cfg:
10193 f
" {solver_path}: 'solution_convergence' moved to "
10194 "monitor.yml -> solution_monitoring.convergence."
10204 required_case_sections = [
'properties',
'run_control',
'grid',
'models',
'boundary_conditions']
10205 for section
in required_case_sections:
10206 if section
not in case_cfg:
10207 errors.append(f
" {case_path}: missing required section '{section}'.")
10213 props = case_cfg.get(
'properties', {})
10214 for group, keys
in [(
'scaling', [
'length_ref',
'velocity_ref']),
10215 (
'fluid', [
'density',
'viscosity'])]:
10216 sub = props.get(group, {})
10218 errors.append(f
" {case_path}: missing 'properties.{group}' section.")
10222 errors.append(f
" {case_path}: missing key 'properties.{group}.{k}'.")
10225 rc = case_cfg.get(
'run_control', {})
10226 for k
in [
'start_step',
'total_steps',
'dt_physical']:
10228 errors.append(f
" {case_path}: missing key 'run_control.{k}'.")
10233 for key, value, positive
in (
10234 (
'properties.fluid.density', props.get(
'fluid', {}).get(
'density', 0),
True),
10235 (
'properties.fluid.viscosity', props.get(
'fluid', {}).get(
'viscosity', 0),
False),
10236 (
'run_control.dt_physical', rc.get(
'dt_physical', 0),
True),
10238 if not isinstance(value, (int, float))
or isinstance(value, bool):
10239 errors.append(f
" {case_path}: '{key}' must be a number (got {value!r}).")
10240 elif positive
and value <= 0:
10241 errors.append(f
" {case_path}: '{key}' must be positive (got {value}).")
10243 errors.append(f
" {case_path}: '{key}' must be non-negative (got {value}).")
10244 particles_count = (((case_cfg.get(
'models')
or {}).get(
'physics')
or {}).get(
'particles')
or {}).get(
'count', 0)
10245 for key, value
in ((
'run_control.start_step', rc.get(
'start_step', 0)),
10246 (
'run_control.total_steps', rc.get(
'total_steps', 0)),
10247 (
'models.physics.particles.count', particles_count)):
10248 if not isinstance(value, int)
or isinstance(value, bool)
or value < 0:
10249 errors.append(f
" {case_path}: '{key}' must be a non-negative integer (got {value!r}).")
10254 grid_cfg = case_cfg.get(
'grid', {})
10255 grid_mode = grid_cfg.get(
'mode')
10256 valid_grid_modes =
list(GRID_MODES)
10257 if grid_mode
not in valid_grid_modes:
10258 errors.append(f
" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
10259 elif grid_mode ==
'file':
10260 source_file = grid_cfg.get(
'source_file')
10261 if not source_file:
10262 errors.append(f
" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
10266 except ValueError
as exc:
10267 errors.append(f
" {case_path}: {exc}")
10269 if not os.path.isfile(source_abs):
10270 errors.append(f
" {case_path}: grid.source_file does not exist: {source_abs}")
10271 elif grid_mode ==
'programmatic_c':
10272 grid_settings = grid_cfg.get(
'programmatic_settings')
10273 if not grid_settings:
10274 errors.append(f
" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
10275 elif not isinstance(grid_settings, dict):
10276 errors.append(f
" {case_path}: 'grid.programmatic_settings' must be a mapping.")
10277 elif grid_mode ==
'grid_gen':
10278 gen_cfg = grid_cfg.get(
'generator')
10279 if not isinstance(gen_cfg, dict):
10280 errors.append(f
" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
10287 config_file = gen_cfg.get(
'config_file')
10288 if not config_file:
10289 errors.append(f
" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
10293 except ValueError
as exc:
10294 errors.append(f
" {case_path}: {exc}")
10296 if not os.path.isfile(config_abs):
10297 errors.append(f
" {case_path}: grid.generator.config_file does not exist: {config_abs}")
10299 grid_type = gen_cfg.get(
'grid_type')
10300 if grid_type
is not None and str(grid_type)
not in GRID_GENERATOR_TYPES:
10301 errors.append(f
" {case_path}: grid.generator.grid_type must be one of "
10302 f
"{list(GRID_GENERATOR_TYPES)} (got '{grid_type}').")
10309 cli_args = gen_cfg.get(
'cli_args', [])
10310 if cli_args
is not None and not isinstance(cli_args, list):
10311 errors.append(f
" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
10314 except ValueError
as e:
10315 errors.append(f
" {case_path}: {e}")
10318 prepared_blocks =
None
10321 except ValueError
as e:
10322 errors.append(f
" {case_path}: {e}")
10325 ic = props.get(
'initial_conditions', {})
10328 ic_start_step = int((case_cfg.get(
"run_control", {})
or {}).get(
"start_step", 0)
or 0)
10329 except (TypeError, ValueError):
10333 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
10338 ic_eulerian_source =
"solve"
10339 ic_is_authoritative = ic_eulerian_source ==
"solve" and ic_start_step == 0
10341 errors.append(f
" {case_path}: missing 'properties.initial_conditions' section.")
10342 elif not isinstance(ic, dict):
10343 errors.append(f
" {case_path}: 'properties.initial_conditions' must be a mapping.")
10344 elif 'mode' not in ic:
10346 f
" {case_path}: missing key 'properties.initial_conditions.mode'. "
10347 "Specify 'generated' or 'file' explicitly."
10349 elif ic_is_authoritative:
10353 ic, prepared_blocks, scales=scaling_contract,
10354 provider_context={
"kinematic_viscosity": scaling_contract[
"nondimensional_kinematic_viscosity"]},
10356 except KeyError
as e:
10357 errors.append(f
" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
10358 except ValueError
as e:
10359 errors.append(f
" {case_path}: {e}")
10360 if ic_is_authoritative
and resolved_ic
and resolved_ic.get(
"kind") ==
"stability_mode_velocity":
10361 dimensionality = str((((case_cfg.get(
"models", {})
or {}).get(
"physics", {})
or {})
10362 .get(
"dimensionality",
"3D"))).strip().upper()
10363 smv = resolved_ic[
"params"]
10364 if dimensionality ==
"2D" and (
"i" in (smv[
"streamwise_axis"], smv[
"cross_axis"])
10365 or not (smv.get(
"noise")
or {
"planar":
True})[
"planar"]):
10366 errors.append(f
" {case_path}: stability_mode_velocity in 2D mode needs i as the normal axis "
10367 "(streamwise and cross axes j and k) and noise.planar: true.")
10368 if (ic_is_authoritative
and resolved_ic
and
10369 GENERATED_IC_PROVIDERS.get(resolved_ic.get(
"kind"), {}).get(
"requires_fresh_3d")):
10370 if resolved_ic[
"kind"]
in (
"channel_spectral_velocity",
"duct_spectral_velocity"):
10371 if (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"fsi", {})
or {}).get(
"immersed",
False):
10372 errors.append(f
" {case_path}: wall spectral ICs do not support immersed geometry.")
10373 dimensionality = str((((case_cfg.get(
"models", {})
or {}).get(
"physics", {})
or {})
10374 .get(
"dimensionality",
"3D"))).strip().upper()
10375 if dimensionality !=
"3D":
10376 errors.append(f
" {case_path}: {resolved_ic['label']} requires models.physics.dimensionality: 3D.")
10377 if grid_mode ==
"programmatic_c":
10378 settings = grid_cfg.get(
"programmatic_settings", {})
or {}
10379 ratios = [settings.get(key, 1.0)
for key
in (
"rxs",
"rys",
"rzs")]
10381 if any(abs(float(value) - 1.0) > 1.0e-12
for value
in ratios):
10382 errors.append(f
" {case_path}: {resolved_ic['label']} requires uniform programmatic spacing (rxs/rys/rzs: 1.0).")
10383 except (TypeError, ValueError):
10385 generated_profile = any(
10386 bc.get(
"handler") ==
"prescribed_flow"
10387 and ((bc.get(
"params")
or {}).get(
"source")
or {}).get(
"type") ==
"generated"
10388 for block
in (prepared_blocks
or [])
for bc
in block
10390 if grid_mode ==
'programmatic_c' and (
10394 except ValueError
as e:
10395 errors.append(f
" {case_path}: {e}")
10398 particles_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'particles', {})
10399 if particles_cfg
and not isinstance(particles_cfg, dict):
10400 errors.append(f
" {case_path}: 'models.physics.particles' must be a mapping.")
10401 elif isinstance(particles_cfg, dict):
10402 init_mode_raw = particles_cfg.get(
'init_mode',
'Surface')
10405 except ValueError
as e:
10406 errors.append(f
" {case_path}: {e}")
10412 random_seed = particles_cfg.get(
'random_seed')
10413 if random_seed
is not None and (isinstance(random_seed, bool)
or not isinstance(random_seed, int)
10414 or not 0 <= random_seed <= 2147483647):
10416 f
" {case_path}: models.physics.particles.random_seed must be an integer from 0 to "
10417 f
"2147483647 (got {random_seed!r})."
10420 restart_mode = particles_cfg.get(
'restart_mode')
10421 if restart_mode
is not None and str(restart_mode).lower()
not in PARTICLE_RESTART_MODES:
10423 f
" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
10425 elif 'restart_mode' not in particles_cfg:
10427 start_step = int(rc.get(
'start_step', 0))
10428 particle_count = int(particles_cfg.get(
'count', 0)
or 0)
10429 except (TypeError, ValueError):
10432 if start_step > 0
and particle_count > 0:
10434 f
"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
10435 "(run_control.start_step > 0, count > 0). C will default to 'load'."
10438 if pinit_code == 2:
10439 point_cfg = particles_cfg.get(
'point_source', {})
10440 if not isinstance(point_cfg, dict):
10441 errors.append(f
" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
10443 for coord
in (
'x',
'y',
'z'):
10444 if coord
not in point_cfg:
10446 f
" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
10449 if 'fields' in particles_cfg:
10452 except ValueError
as e:
10453 errors.append(f
" {case_path}: {e}")
10454 particle_fields = []
10455 if particle_fields
and int(particles_cfg.get(
'count', 0)
or 0) <= 0:
10456 errors.append(f
" {case_path}: models.physics.particles.fields needs particles (count > 0).")
10457 if particle_fields
and ((solver_cfg.get(
'verification')
or {}).get(
'sources')
or {}).get(
'scalar'):
10459 f
" {case_path}: models.physics.particles.fields cannot be combined with "
10460 f
"{solver_path} verification.sources.scalar, which prescribes Psi at every step.")
10461 if pinit_code == 2:
10462 for entry
in particle_fields:
10463 if not any(re.search(
r"\b(uniform|normal)\s*\(", e)
for e
in entry[
"expressions"]):
10465 f
"{case_path}: models.physics.particles.fields.{entry['name']} has no random draw, "
10466 "but init_mode is PointSource: every particle starts at one point and gets the "
10467 "same value. Use a constant, or uniform()/normal() for per-particle values.")
10469 resumes = int(rc.get(
'start_step', 0)) > 0
10470 except (TypeError, ValueError):
10472 if particle_fields
and resumes
and str(particles_cfg.get(
'restart_mode',
'load')).lower() ==
'load':
10474 f
"{case_path}: models.physics.particles.fields is ignored when particles are restored "
10475 "(restart_mode: load); their values come from the checkpoint.")
10482 models_cfg = case_cfg.get(
'models', {})
or {}
10483 domain_cfg = models_cfg.get(
'domain', {})
or {}
10484 physics_cfg = models_cfg.get(
'physics', {})
or {}
10486 block_count = int(domain_cfg.get(
'blocks', 1))
10487 except (TypeError, ValueError):
10489 if block_count != 1:
10491 f
" {case_path}: models.domain.blocks must be 1 (got {domain_cfg.get('blocks')!r}). "
10492 "Multi-block domains are planned, not implemented: no solver exchanges data across "
10493 "the faces blocks share, so each block would be solved as an isolated domain."
10495 fsi_cfg = physics_cfg.get(
'fsi', {})
or {}
10496 if not isinstance(fsi_cfg, dict):
10497 errors.append(f
" {case_path}: 'models.physics.fsi' must be a mapping.")
10499 for key, feature
in ((
'immersed',
'An immersed boundary'),
10500 (
'moving_fsi',
'Moving-body fluid-structure interaction')):
10501 if fsi_cfg.get(key):
10503 f
" {case_path}: models.physics.fsi.{key} must be false. {feature} is planned, "
10504 "not implemented: no body geometry is loaded, and the interpolation that would "
10505 "impose one on the flow was never ported."
10507 dimensionality = str(physics_cfg.get(
'dimensionality',
'3D')).strip()
10508 if dimensionality
not in DOMAIN_DIMENSIONALITY_MODES:
10510 f
" {case_path}: models.physics.dimensionality must be one of "
10511 f
"{list(DOMAIN_DIMENSIONALITY_MODES)} (got '{dimensionality}')."
10515 turbulence_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'turbulence', {})
10516 if turbulence_cfg
is not None and not isinstance(turbulence_cfg, dict):
10517 errors.append(f
" {case_path}: 'models.physics.turbulence' must be a mapping.")
10518 elif isinstance(turbulence_cfg, dict)
and turbulence_cfg:
10523 {
"length_ref": 1.0,
"velocity_ref": 1.0})
10524 except ValueError
as e:
10525 errors.append(f
" {case_path}: {e}")
10527 les_cfg = turbulence_cfg.get(
'les')
10528 rans_cfg = turbulence_cfg.get(
'rans')
10529 wall_cfg = turbulence_cfg.get(
'wall_function')
10531 if isinstance(les_cfg, dict):
10537 if rans_cfg
is not None:
10538 errors.append(f
" {case_path}: {RANS_PLANNED_REFUSAL}")
10540 if isinstance(wall_cfg, dict):
10541 if 'enabled' in wall_cfg
and not isinstance(wall_cfg[
'enabled'], bool):
10542 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
10543 if 'roughness_height' in wall_cfg:
10545 value = float(wall_cfg[
'roughness_height'])
10547 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
10548 except (TypeError, ValueError):
10549 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
10557 if wall_model
in (2, 3):
10559 f
" {case_path}: models.physics.turbulence.wall_function.roughness_height "
10560 "applies only to model 'log_law'; 'werner' has no roughness formulation and "
10561 "'cabot' ignores it. Remove the key or select 'log_law'.")
10564 if not isinstance(solver_cfg, dict)
or not solver_cfg:
10565 errors.append(f
" {solver_path}: solver config is empty or not a valid YAML mapping.")
10567 strategy_cfg = solver_cfg.get(
'strategy', {})
10568 if not isinstance(strategy_cfg, dict):
10569 errors.append(f
" {solver_path}: 'strategy' must be a mapping.")
10570 elif 'implicit' in strategy_cfg:
10572 f
" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
10573 "Use 'strategy.momentum_solver' with named solver values."
10575 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
10578 except ValueError
as e:
10579 errors.append(f
" {solver_path}: {e}")
10581 op_mode_cfg = solver_cfg.get(
'operation_mode', {})
10582 if op_mode_cfg
is not None and not isinstance(op_mode_cfg, dict):
10583 errors.append(f
" {solver_path}: 'operation_mode' must be a mapping when provided.")
10584 elif isinstance(op_mode_cfg, dict):
10585 eulerian_source_mode =
None
10586 normalized_analytical_type =
None
10587 if 'eulerian_field_source' in op_mode_cfg:
10590 except ValueError
as e:
10591 errors.append(f
" {solver_path}: {e}")
10593 analytical_type = op_mode_cfg.get(
'analytical_type')
10594 if analytical_type
is not None:
10597 except ValueError
as e:
10598 errors.append(f
" {solver_path}: {e}")
10600 uniform_flow_cfg = op_mode_cfg.get(
'uniform_flow')
10601 if uniform_flow_cfg
is not None and not isinstance(uniform_flow_cfg, dict):
10602 errors.append(f
" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
10603 elif normalized_analytical_type ==
"UNIFORM_FLOW":
10604 if not isinstance(uniform_flow_cfg, dict):
10606 f
" {solver_path}: operation_mode.uniform_flow is required when "
10607 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
10610 for coord
in (
"u",
"v",
"w"):
10611 if coord
not in uniform_flow_cfg:
10613 f
" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
10617 float(uniform_flow_cfg[coord])
10618 except (TypeError, ValueError):
10620 f
" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
10622 elif uniform_flow_cfg
is not None:
10624 f
" {solver_path}: operation_mode.uniform_flow is only valid when "
10625 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
10628 if eulerian_source_mode ==
"analytical":
10629 effective_analytical_type = normalized_analytical_type
or "TGV3D"
10630 if effective_analytical_type ==
"TGV3D":
10633 if grid_mode
not in (GRID_MODES[1], GRID_MODES[0]):
10635 f
" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
10636 "'programmatic_c' (solver-assigned TGV domain) or 'file' (a grid file the field "
10637 "is evaluated on)."
10639 elif grid_mode ==
'programmatic_c' and isinstance(grid_cfg.get(
'programmatic_settings'), dict):
10640 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
10643 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
10644 f
"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
10647 if grid_mode
not in (GRID_MODES[1], GRID_MODES[0]):
10649 f
" {case_path}: grid.mode '{grid_mode}' is not supported when "
10650 f
"operation_mode.analytical_type is '{effective_analytical_type}'. "
10651 "Use 'programmatic_c' or 'file'."
10653 elif grid_mode ==
'programmatic_c' and isinstance(grid_cfg.get(
'programmatic_settings'), dict):
10654 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
10657 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
10658 f
"operation_mode.analytical_type is '{effective_analytical_type}' and "
10659 "grid.mode is 'programmatic_c'."
10662 verification_cfg = solver_cfg.get(
'verification', {})
10663 if verification_cfg
is not None and not isinstance(verification_cfg, dict):
10664 errors.append(f
" {solver_path}: 'verification' must be a mapping when provided.")
10665 elif isinstance(verification_cfg, dict)
and verification_cfg:
10666 sources_cfg = verification_cfg.get(
'sources', {})
10667 if sources_cfg
is not None and not isinstance(sources_cfg, dict):
10668 errors.append(f
" {solver_path}: 'verification.sources' must be a mapping when provided.")
10669 elif isinstance(sources_cfg, dict)
and sources_cfg:
10670 diff_cfg = sources_cfg.get(
'diffusivity')
10671 scalar_cfg = sources_cfg.get(
'scalar')
10673 if diff_cfg
is not None:
10674 if not isinstance(diff_cfg, dict):
10675 errors.append(f
" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
10677 if eulerian_source_mode !=
"analytical":
10679 f
" {solver_path}: verification.sources.diffusivity is only valid when "
10680 "operation_mode.eulerian_field_source is 'analytical'."
10682 mode = diff_cfg.get(
'mode')
10683 profile = diff_cfg.get(
'profile')
10684 if str(mode).strip().lower() !=
"analytical":
10686 f
" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
10688 if str(profile).strip().upper() !=
"LINEAR_X":
10690 f
" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
10692 for key
in (
"gamma0",
"slope_x"):
10693 if key
not in diff_cfg:
10695 f
" {solver_path}: verification.sources.diffusivity.{key} is required."
10699 float(diff_cfg[key])
10700 except (TypeError, ValueError):
10702 f
" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
10705 if scalar_cfg
is not None:
10706 if not isinstance(scalar_cfg, dict):
10707 errors.append(f
" {solver_path}: 'verification.sources.scalar' must be a mapping.")
10709 if eulerian_source_mode !=
"analytical":
10711 f
" {solver_path}: verification.sources.scalar is only valid when "
10712 "operation_mode.eulerian_field_source is 'analytical'."
10714 mode = scalar_cfg.get(
'mode')
10715 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
10716 if str(mode).strip().lower() !=
"analytical":
10718 f
" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
10720 if profile
not in VERIFICATION_SCALAR_PROFILES:
10722 f
" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
10724 required_scalar_keys = {
10725 "CONSTANT": (
"value",),
10726 "LINEAR_X": (
"phi0",
"slope_x"),
10727 "SIN_PRODUCT": (
"amplitude",
"kx",
"ky",
"kz"),
10729 for key
in required_scalar_keys:
10730 if key
not in scalar_cfg:
10732 f
" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
10736 float(scalar_cfg[key])
10737 except (TypeError, ValueError):
10739 f
" {solver_path}: verification.sources.scalar.{key} must be numeric."
10742 unknown_source_keys = sorted(set(sources_cfg.keys()) - {
"diffusivity",
"scalar"})
10743 if unknown_source_keys:
10745 f
" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
10746 "Currently supported: 'diffusivity', 'scalar'."
10748 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {
"sources"})
10749 if unknown_verification_keys:
10751 f
" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
10752 "Currently supported: 'sources'."
10757 except ValueError
as exc:
10758 errors.append(f
" {solver_path}: {exc}")
10761 except ValueError
as exc:
10762 errors.append(f
" {monitor_path}: {exc}")
10764 tolerances_cfg = solver_cfg.get(
'tolerances', {})
10765 if tolerances_cfg
is not None and not isinstance(tolerances_cfg, dict):
10766 errors.append(f
" {solver_path}: 'tolerances' must be a mapping when provided.")
10767 elif isinstance(tolerances_cfg, dict):
10768 for key
in (
"absolute_tol",
"relative_tol",
"residual_absolute_tol",
"residual_relative_tol"):
10769 if key
in tolerances_cfg:
10771 float(tolerances_cfg[key])
10772 except (TypeError, ValueError):
10773 errors.append(f
" {solver_path}: tolerances.{key} must be numeric.")
10775 ms_cfg = solver_cfg.get(
'momentum_solver', {})
10776 if ms_cfg
is not None and not isinstance(ms_cfg, dict):
10777 errors.append(f
" {solver_path}: 'momentum_solver' must be a mapping when provided.")
10778 elif isinstance(ms_cfg, dict):
10779 unsupported_flat_keys = {
10780 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
10781 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
10782 'rk4_residual_noise_allowance_factor'
10784 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
10785 if present_unsupported:
10787 f
" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
10788 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
10791 allowed_ms_keys = {
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4',
'newton_krylov'}
10792 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
10793 if unknown_ms_keys:
10795 f
" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
10796 "Currently supported: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
10798 if 'dual_time_picard_jameson_rk' in ms_cfg
and 'dual_time_picard_rk4' in ms_cfg:
10800 f
" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
10801 "do not also set its deprecated dual_time_picard_rk4 alias."
10804 selected_solver =
None
10805 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
10810 if selected_solver
is None:
10811 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
10813 has_dualtime_block = (
10814 'dual_time_picard_jameson_rk' in ms_cfg
or 'dual_time_picard_rk4' in ms_cfg
10816 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
10818 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
10819 f
"{selected_solver}. Solver-specific blocks must match the selected solver."
10822 newton_cfg = ms_cfg.get(
'newton_krylov')
10823 if newton_cfg
is not None:
10824 if selected_solver !=
"newton_krylov":
10826 f
" {solver_path}: momentum_solver.newton_krylov is set but selected solver is "
10827 f
"{selected_solver}. Solver-specific blocks must match the selected solver."
10831 except ValueError
as exc:
10832 errors.append(f
" {solver_path}: {exc}")
10834 dt_picard_cfg = ms_cfg.get(
'dual_time_picard_jameson_rk', ms_cfg.get(
'dual_time_picard_rk4'))
10835 if dt_picard_cfg
is not None:
10836 if not isinstance(dt_picard_cfg, dict):
10837 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
10839 allowed_dt_keys = {
10840 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
10841 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
10842 'rk4_residual_noise_allowance_factor',
'ratio_ema_alpha'
10844 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
10845 if unknown_dt_keys:
10847 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
10849 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
10850 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
10852 f
" {solver_path}: use only jameson_residual_noise_allowance_factor; "
10853 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
10855 if 'pseudo_cfl' in dt_picard_cfg:
10856 pcfl_cfg = dt_picard_cfg[
'pseudo_cfl']
10857 if not isinstance(pcfl_cfg, dict):
10858 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
10860 allowed_pcfl_keys = {
'initial',
'minimum',
'maximum',
'growth_factor',
'reduction_factor'}
10861 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
10862 if unknown_pcfl_keys:
10864 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
10867 for key
in allowed_pcfl_keys:
10868 if key
in pcfl_cfg:
10870 numeric_pcfl[key] = float(pcfl_cfg[key])
10871 except (TypeError, ValueError):
10873 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
10875 if numeric_pcfl.get(
'minimum', 1.0) <= 0.0:
10876 errors.append(f
" {solver_path}: pseudo_cfl.minimum must be positive.")
10877 if numeric_pcfl.get(
'growth_factor', 1.0) < 1.0:
10878 errors.append(f
" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
10879 reduction = numeric_pcfl.get(
'reduction_factor', 1.0)
10880 if reduction <= 0.0
or reduction >= 1.0:
10881 errors.append(f
" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
10882 if all(key
in numeric_pcfl
for key
in (
'minimum',
'initial',
'maximum')):
10883 if not numeric_pcfl[
'minimum'] <= numeric_pcfl[
'initial'] <= numeric_pcfl[
'maximum']:
10884 errors.append(f
" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
10886 'jameson_residual_noise_allowance_factor'
10887 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
10888 else 'rk4_residual_noise_allowance_factor'
10890 if noise_key
in dt_picard_cfg:
10892 if float(dt_picard_cfg[noise_key]) < 1.0:
10893 errors.append(f
" {solver_path}: {noise_key} must be at least 1.")
10894 except (TypeError, ValueError):
10895 errors.append(f
" {solver_path}: {noise_key} must be numeric.")
10896 if 'ratio_ema_alpha' in dt_picard_cfg:
10898 alpha_val = float(dt_picard_cfg[
'ratio_ema_alpha'])
10899 if not 0.0 <= alpha_val <= 1.0:
10900 errors.append(f
" {solver_path}: ratio_ema_alpha must be in [0, 1].")
10901 except (TypeError, ValueError):
10902 errors.append(f
" {solver_path}: ratio_ema_alpha must be numeric.")
10905 interp_cfg = solver_cfg.get(
'interpolation', {})
if isinstance(solver_cfg, dict)
else {}
10906 if interp_cfg
is not None and not isinstance(interp_cfg, dict):
10907 errors.append(f
" {solver_path}: 'interpolation' must be a mapping when provided.")
10908 elif isinstance(interp_cfg, dict)
and 'method' in interp_cfg:
10911 except ValueError
as e:
10912 errors.append(f
" {solver_path}: {e}")
10915 op_mode = solver_cfg.get(
'operation_mode')
if isinstance(solver_cfg.get(
'operation_mode'), dict)
else {}
10916 if (interp_code == 2
and str(op_mode.get(
'eulerian_field_source',
'solve')).strip().lower() ==
'analytical'
10917 and str(op_mode.get(
'analytical_type',
'TGV3D')).strip().upper() !=
'UNIFORM_FLOW'):
10919 f
" {solver_path}: interpolation.method 'FaceFlux' needs the face fluxes the flow is "
10920 f
"built from, which analytical type '{op_mode.get('analytical_type', 'TGV3D')}' does not set; "
10921 "use 'Trilinear', or the UNIFORM_FLOW analytical type.")
10924 if not isinstance(monitor_cfg, dict)
or not monitor_cfg:
10925 errors.append(f
" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
10927 io_cfg = monitor_cfg.get(
'io', {})
10928 freq = io_cfg.get(
'data_output_frequency')
10929 if freq
is not None and (
not isinstance(freq, int)
or freq <= 0):
10930 errors.append(f
" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
10931 for key
in (
'write_initial_checkpoint',
'write_final_checkpoint'):
10932 if key
in io_cfg
and not isinstance(io_cfg[key], bool):
10933 errors.append(f
" {monitor_path}: 'io.{key}' must be true or false (got {io_cfg[key]!r}).")
10935 physics_cfg = (case_cfg.get(
'models')
or {}).get(
'physics')
if isinstance(case_cfg, dict)
and isinstance(case_cfg.get(
'models')
or {}, dict)
else None
10936 particles_cfg = physics_cfg.get(
'particles')
if isinstance(physics_cfg, dict)
else None
10937 particle_count = particles_cfg.get(
'count', 0)
if isinstance(particles_cfg, dict)
else 0
10939 errors.append(f
" {monitor_path}: 'logging.trace_particles' needs particles; "
10940 "models.physics.particles.count is 0.")
10941 except ValueError
as e:
10942 errors.append(f
" {monitor_path}: {e}")
10943 particle_console_freq = io_cfg.get(
'particle_console_output_frequency')
10944 if particle_console_freq
is not None and (
not isinstance(particle_console_freq, int)
or particle_console_freq < 0):
10946 f
" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
10947 f
"(got {particle_console_freq})."
10950 monitor_cfg, monitor_path
10952 errors.extend(containment_errors)
10953 warnings.extend(containment_warnings)
10955 monitor_cfg, monitor_path,
"monitor passthrough"))
10958 except ValueError
as e:
10959 errors.append(f
" {monitor_path}: {e}")
10962 except ValueError
as e:
10963 errors.append(f
" {monitor_path}: {e}")
10966 except ValueError
as e:
10967 errors.append(f
" {monitor_path}: {e}")
10970 except ValueError
as e:
10971 errors.append(f
" {monitor_path}: {e}")
10972 statistics_console_freq = io_cfg.get(
'statistics_console_output_frequency')
10973 if statistics_console_freq
is not None and (
10974 not isinstance(statistics_console_freq, int)
10975 or isinstance(statistics_console_freq, bool)
10976 or statistics_console_freq < 0):
10978 f
" {monitor_path}: 'io.statistics_console_output_frequency' must be a non-negative "
10979 f
"integer (got {statistics_console_freq})."
10983 except ValueError
as e:
10984 errors.append(f
" {monitor_path}: {e}")
10986 if not errors
and not restart_source_given:
10989 f
"{case_path}: This configuration requires restart data (start_step > 0, "
10990 "eulerian_field_source='load', or particle restart_mode='load'). "
10991 "Use --restart-from or --continue when running."
10996 for warning
in warnings:
10997 print(f
"[WARNING] {warning}", file=sys.stderr)
11001 monitor_path: str =
"monitor.yml") ->
"tuple[list, list]":
11003 @brief Report post step selections that cannot land on a committed checkpoint.
11005 @details The post-processor reads committed bundles, and the solver commits one
11006 every `io.data_output_frequency` completed steps. A `step_interval` that
11007 is not a multiple of that cadence therefore asks for steps that were
11008 never written: those steps wait forever for a checkpoint, and far less
11009 than the recipe requested is processed. That is only
11010 discovered after a solve has already run, so it is caught here instead.
11012 The solver also commits the initial and final states off cadence, which
11013 is why a misaligned `start_step` is a warning rather than an error: it
11014 may legitimately be the run's initial or final step. A bounded selection
11015 containing only one checkpoint has no stride to align; its interval must
11016 still be positive. Multi-checkpoint selections retain the cadence check.
11018 @param[in] post_cfg Parsed post-processing configuration.
11019 @param[in] monitor_cfg Parsed monitor configuration governing the source run.
11020 @param[in] post_path Path to the post file, for error messages.
11021 @param[in] monitor_path Path to the monitor file, for error messages.
11022 @return `(errors, warnings)`, each a list of message strings. Both are empty when
11023 the comparison cannot be made, because the inputs that would make it
11024 meaningful are validated and reported elsewhere.
11028 if not isinstance(post_cfg, dict)
or not isinstance(monitor_cfg, dict):
11029 return errors, warnings
11031 io_cfg = monitor_cfg.get(
"io")
or {}
11032 if not isinstance(io_cfg, dict):
11033 return errors, warnings
11035 cadence = int(io_cfg[
"data_output_frequency"])
11036 except (KeyError, TypeError, ValueError):
11038 return errors, warnings
11042 return errors, warnings
11046 except (TypeError, ValueError):
11048 return errors, warnings
11050 single_checkpoint = end_step >= start_step
and end_step - start_step < step_interval
11051 if not single_checkpoint
and step_interval % cadence != 0:
11052 suggestion = max(cadence, (step_interval // cadence) * cadence)
11054 f
" {post_path}: 'run_control.step_interval' is {step_interval}, which is not a "
11055 f
"multiple of 'io.data_output_frequency' ({cadence}) in {monitor_path}. The solver "
11056 f
"only commits a checkpoint every {cadence} steps, so most requested steps were "
11057 f
"never written and post-processing would stop at the first missing one. Use "
11058 f
"{suggestion}, or another multiple of {cadence}, or lower the monitor cadence."
11061 if start_step > 0
and start_step % cadence != 0:
11063 f
"{post_path}: 'run_control.start_step' is {start_step}, which is not a multiple of "
11064 f
"'io.data_output_frequency' ({cadence}) in {monitor_path}. That step only exists if "
11065 f
"it is the run's initial or final step, which is committed off cadence."
11068 return errors, warnings
11073 @brief Validates the post-processing config before running the post-processor.
11074 @param[in] post_cfg Parsed post-processing YAML dictionary.
11075 @param[in] post_path Path to post file (for error messages).
11076 @param[in] monitor_cfg Parsed monitor configuration, when available. Two checks
11077 span both files: field statistics, where post.yml names
11078 the windows and monitor.yml decides what each accumulates,
11079 and step cadence, where monitor.yml decides which steps
11080 exist to be read. Both run only when the monitor is known.
11081 @param[in] case_cfg Parsed case configuration, when available. Spectra
11082 preconditions need the boundary conditions and block count, so
11083 they are checked only when the case is known; validating a
11084 recipe on its own still checks it on its own terms.
11085 @throws SystemExit on validation failure.
11094 except ValueError
as e:
11096 errors.append(f
" {post_path}: {e}")
11100 except ValueError
as e:
11101 spectra_recipe =
None
11102 errors.append(f
" {post_path}: {e}")
11103 if recipe
is not None:
11106 except ValueError
as e:
11107 errors.append(f
" {post_path}: {e}")
11108 if spectra_recipe
and spectra_recipe[
"tasks"]
and case_cfg
is not None:
11110 if recipe
and recipe[
"windows"]
and monitor_cfg
is not None:
11116 if configured
is not None:
11117 if not configured[
"enabled"]:
11119 f
" {post_path}: field statistics are requested, but "
11120 "'field_statistics.enabled' is not set in the monitor configuration, so no "
11121 "window is accumulated."
11124 by_name = {window[
"name"]: window
for window
in configured[
"windows"]}
11125 for name
in recipe[
"windows"]:
11126 if name
not in by_name:
11128 f
" {post_path}: field-statistics window '{name}' is not defined in "
11129 f
"the monitor configuration. Defined windows: {sorted(by_name)}."
11133 f
" {post_path}: outputs {recipe['outputs']} produce no field for "
11134 f
"window '{name}'; it accumulates none of the state they need. Add "
11135 "'second' to a field's moments for stresses, RMS, or turbulent "
11136 "kinetic energy, or a covariance for a flux."
11139 for ensemble
in (recipe
or {}).get(
"ensembles", []):
11143 f
" {post_path}: outputs {recipe['outputs']} produce no field for ensemble "
11144 f
"'{ensemble['name']}'; add 'second' to a field's moments or a covariance."
11146 if case_cfg
is not None:
11147 requirements = {eligible[entry[
"field"]][
"requires"]
for entry
in ensemble[
"fields"]}
11148 for requirement
in sorted(r
for r
in requirements
if r):
11151 f
" {post_path}: ensemble '{ensemble['name']}' needs the '{requirement}' "
11152 "subsystem, which is not enabled for this case."
11155 if not isinstance(post_cfg, dict)
or not post_cfg:
11156 errors.append(f
" {post_path}: post-processing config is empty or not a valid YAML mapping.")
11160 if 'run_control' not in post_cfg:
11161 errors.append(f
" {post_path}: missing required section 'run_control'.")
11163 rc = post_cfg.get(
'run_control', {})
11164 if not isinstance(rc, dict):
11165 errors.append(f
" {post_path}: 'run_control' must be a mapping.")
11167 for canonical_key, aliases
in POST_RUN_CONTROL_ALIASES.items():
11168 if not any(alias
in rc
for alias
in aliases):
11169 alias_list =
"', '".join(aliases)
11171 f
" {post_path}: missing required key 'run_control.{canonical_key}' "
11172 f
"(accepted aliases: '{alias_list}')."
11177 value = int(raw_value)
11178 if canonical_key ==
"step_interval" and value <= 0:
11179 errors.append(f
" {post_path}: 'run_control.step_interval' must be positive.")
11180 except (TypeError, ValueError):
11181 alias_name = next((alias
for alias
in aliases
if alias
in rc), canonical_key)
11183 f
" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
11187 io_cfg = post_cfg.get(
'io', {})
11188 source_cfg = post_cfg.get(
'source_data')
11189 if source_cfg
is not None and not isinstance(source_cfg, dict):
11190 errors.append(f
" {post_path}: 'source_data' must be a mapping when provided.")
11191 global_ops = post_cfg.get(
'global_operations')
11192 if global_ops
is not None:
11193 if not isinstance(global_ops, dict):
11194 errors.append(f
" {post_path}: 'global_operations' must be a mapping when provided.")
11195 elif 'dimensionalize' in global_ops
and not isinstance(global_ops.get(
'dimensionalize'), bool):
11196 errors.append(f
" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
11198 errors.append(f
" {post_path}: missing required section 'io'.")
11199 elif not isinstance(io_cfg, dict):
11200 errors.append(f
" {post_path}: 'io' must be a mapping.")
11202 for k
in [
'output_filename_prefix']:
11203 if k
not in io_cfg:
11204 errors.append(f
" {post_path}: missing required key 'io.{k}'.")
11205 for key_name
in (
'output_directory',
'output_filename_prefix',
'particle_filename_prefix'):
11206 if key_name
in io_cfg
and not isinstance(io_cfg.get(key_name), str):
11207 errors.append(f
" {post_path}: 'io.{key_name}' must be a string when provided.")
11210 if 'output_directory' in io_cfg:
11212 f
"{post_path}: 'io.output_directory' is ignored; post output goes to the run's "
11213 "visualization and analysis directories under the recipe's identity, so recipes "
11214 "differing only in this key share one home and overwrite each other. Change "
11215 "'io.output_filename_prefix' to keep them apart."
11217 if 'output_particles' in io_cfg
and not isinstance(io_cfg.get(
'output_particles'), bool):
11218 errors.append(f
" {post_path}: 'io.output_particles' must be a boolean when provided.")
11221 except ValueError
as exc:
11222 errors.append(f
" {post_path}: {exc}")
11223 particle_subsampling_frequency = io_cfg.get(
'particle_subsampling_frequency')
11224 if particle_subsampling_frequency
is not None:
11225 if not isinstance(particle_subsampling_frequency, int)
or particle_subsampling_frequency <= 0:
11227 f
" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
11229 input_extensions = io_cfg.get(
'input_extensions')
11231 if input_extensions
is not None:
11232 if not isinstance(input_extensions, dict):
11233 errors.append(f
" {post_path}: 'io.input_extensions' must be a mapping when provided.")
11235 for ext_key
in (
'eulerian',
'particle'):
11236 ext_val = input_extensions.get(ext_key)
11237 if ext_val
is not None and not isinstance(ext_val, str):
11238 errors.append(f
" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
11239 elif ext_val
is not None and str(ext_val).strip().lstrip(
'.').lower() !=
'dat':
11241 f
" {post_path}: 'io.input_extensions.{ext_key}' must be 'dat'; "
11242 "committed checkpoint payload names are fixed."
11244 if source_input_extensions
is not None:
11245 if not isinstance(source_input_extensions, dict):
11246 errors.append(f
" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
11248 for ext_key
in (
'eulerian',
'particle'):
11249 ext_val = source_input_extensions.get(ext_key)
11250 if ext_val
is not None and not isinstance(ext_val, str):
11252 f
" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
11254 elif ext_val
is not None and str(ext_val).strip().lstrip(
'.').lower() !=
'dat':
11256 f
" {post_path}: 'source_data.input_extensions.{ext_key}' must be 'dat'; "
11257 "committed checkpoint payload names are fixed."
11260 for list_key
in (
'eulerian_fields',
'particle_fields'):
11261 list_val = io_cfg.get(list_key)
11262 if list_val
is not None and (
not isinstance(list_val, list)
11263 or not all(isinstance(item, str)
for item
in list_val)):
11264 errors.append(f
" {post_path}: 'io.{list_key}' must be a list of field names when provided.")
11267 eulerian_fields = io_cfg.get(
'eulerian_fields')
11268 for cell_field
in (
'Ucat',
'P',
'Psi',
'Qcrit'):
11269 if isinstance(eulerian_fields, list)
and cell_field
in eulerian_fields:
11270 after =
" after q_criterion" if cell_field ==
'Qcrit' else ""
11272 f
" {post_path}: 'io.eulerian_fields' lists '{cell_field}', which is cell-centred and "
11273 f
"cannot be written as point data. Add a nodal_average task (input_field: {cell_field}, "
11274 f
"output_field: {cell_field}_nodal){after} and write '{cell_field}_nodal'."
11278 eulerian_pipeline = post_cfg.get(
'eulerian_pipeline', [])
11279 if eulerian_pipeline
is not None and not isinstance(eulerian_pipeline, list):
11280 errors.append(f
" {post_path}: 'eulerian_pipeline' must be a list when provided.")
11281 eulerian_pipeline = []
11282 for i, entry
in enumerate(eulerian_pipeline):
11283 if not isinstance(entry, dict)
or 'task' not in entry:
11284 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
11285 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
11287 task_name = entry.get(
'task')
11288 if task_name ==
'q_criterion':
11290 if task_name ==
'nodal_average':
11291 in_field = entry.get(
'input_field')
11292 out_field = entry.get(
'output_field')
11293 if not isinstance(in_field, str)
or not in_field.strip():
11294 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
11295 if not isinstance(out_field, str)
or not out_field.strip():
11296 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
11297 if isinstance(in_field, str)
and isinstance(out_field, str)
and in_field == out_field:
11299 f
" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
11302 if task_name ==
'normalize_field':
11303 field = entry.get(
'field',
'P')
11304 if not isinstance(field, str)
or not field.strip():
11305 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
11308 f
" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
11309 f
"(got '{field}')."
11311 reference_point = entry.get(
'reference_point', [1, 1, 1])
11312 if not isinstance(reference_point, (list, tuple))
or len(reference_point) != 3:
11314 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
11317 for rp_idx, coord
in enumerate(reference_point):
11320 except (TypeError, ValueError):
11322 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
11323 "must be integer-compatible."
11327 f
" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]. "
11328 f
"Available tasks: {list(POST_EULERIAN_PIPELINE_TASKS)}."
11332 lagrangian_pipeline = post_cfg.get(
'lagrangian_pipeline', [])
11333 if lagrangian_pipeline
is not None and not isinstance(lagrangian_pipeline, list):
11334 errors.append(f
" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
11335 lagrangian_pipeline = []
11336 for i, entry
in enumerate(lagrangian_pipeline):
11337 if not isinstance(entry, dict)
or 'task' not in entry:
11338 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
11340 task_name = entry.get(
'task')
11341 if task_name ==
'specific_ke':
11342 in_field = entry.get(
'input_field')
11343 out_field = entry.get(
'output_field')
11344 if not isinstance(in_field, str)
or not in_field.strip():
11345 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
11346 if not isinstance(out_field, str)
or not out_field.strip():
11347 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
11350 f
" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
11354 stats_cfg = post_cfg.get(
'statistics_pipeline')
11356 if stats_cfg
is not None:
11357 if isinstance(stats_cfg, list):
11358 stats_entries = stats_cfg
11359 elif isinstance(stats_cfg, dict):
11360 stats_entries = stats_cfg.get(
'tasks', [])
11361 if not isinstance(stats_entries, list):
11362 errors.append(f
" {post_path}: 'statistics_pipeline.tasks' must be a list.")
11363 stats_output_prefix = stats_cfg.get(
'output_prefix')
11364 if stats_output_prefix
is not None and not isinstance(stats_output_prefix, str):
11365 errors.append(f
" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
11368 f
" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
11370 for i, entry
in enumerate(stats_entries):
11371 if isinstance(entry, str):
11373 elif isinstance(entry, dict)
and 'task' in entry:
11374 task_name = entry.get(
'task')
11377 f
" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
11382 except ValueError
as e:
11383 errors.append(f
" {post_path}: {e}")
11390 particles_cfg = (((case_cfg.get(
'models')
or {}).get(
'physics')
or {}).get(
'particles')
or {})
11392 particle_count = int(particles_cfg.get(
'count', 0)
or 0)
11393 except (TypeError, ValueError):
11395 init_mode = particles_cfg.get(
'init_mode',
'Surface')
11399 is_point_source =
True
11400 if particle_count > 0
and not is_point_source:
11402 f
" {post_path}: statistics task 'msd' measures displacement from "
11403 f
"models.physics.particles.point_source, so it requires init_mode: PointSource "
11404 f
"(the case seeds with '{init_mode}')."
11407 legacy_stats_output_prefix = post_cfg.get(
'statistics_output_prefix')
11408 if legacy_stats_output_prefix
is not None and not isinstance(legacy_stats_output_prefix, str):
11409 errors.append(f
" {post_path}: 'statistics_output_prefix' must be a string when provided.")
11414 post_cfg, monitor_cfg, post_path)
11415 errors.extend(cadence_errors)
11416 warnings.extend(cadence_warnings)
11420 for warning
in warnings:
11421 print(f
"[WARNING] {warning}", file=sys.stderr)
11425 @brief Validate Slurm scheduler configuration from cluster.yml.
11426 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
11427 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
11432 if not isinstance(cluster_cfg, dict)
or not cluster_cfg:
11433 errors.append(f
" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
11436 scheduler = cluster_cfg.get(
"scheduler", {})
11437 if not isinstance(scheduler, dict):
11438 errors.append(f
" {cluster_path}: 'scheduler' must be a mapping.")
11440 scheduler_type = scheduler.get(
"type",
"slurm")
11441 if str(scheduler_type).lower() !=
"slurm":
11442 errors.append(f
" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
11444 resources = cluster_cfg.get(
"resources", {})
11445 if not isinstance(resources, dict):
11446 errors.append(f
" {cluster_path}: 'resources' must be a mapping.")
11448 for req
in (
"account",
"nodes",
"ntasks_per_node",
"mem",
"time"):
11449 if req
not in resources:
11450 errors.append(f
" {cluster_path}: missing required key 'resources.{req}'.")
11451 for int_key
in (
"nodes",
"ntasks_per_node"):
11452 if int_key
in resources:
11453 val = resources.get(int_key)
11454 if not isinstance(val, int)
or val <= 0:
11455 errors.append(f
" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
11456 for str_key
in (
"account",
"mem",
"time",
"partition"):
11457 if str_key
in resources
and resources.get(str_key)
is not None:
11458 if not isinstance(resources.get(str_key), str):
11459 errors.append(f
" {cluster_path}: resources.{str_key} must be a string when provided.")
11460 if isinstance(resources.get(
"time"), str):
11463 except ValueError
as exc:
11465 f
" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
11467 account = resources.get(
"account")
11468 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
11470 f
"{cluster_path}: resources.account still uses the sample placeholder "
11471 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
11474 notifications = cluster_cfg.get(
"notifications", {})
11475 if notifications
is not None and not isinstance(notifications, dict):
11476 errors.append(f
" {cluster_path}: 'notifications' must be a mapping when provided.")
11477 elif isinstance(notifications, dict):
11478 mail_user = notifications.get(
"mail_user")
11480 errors.append(f
" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
11481 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
11483 f
"{cluster_path}: notifications.mail_user still uses the sample placeholder "
11484 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
11486 mail_type = notifications.get(
"mail_type")
11487 if mail_type
is not None and not isinstance(mail_type, str):
11488 errors.append(f
" {cluster_path}: notifications.mail_type must be a string when provided.")
11490 execution = cluster_cfg.get(
"execution", {})
11491 if execution
is not None and not isinstance(execution, dict):
11492 errors.append(f
" {cluster_path}: 'execution' must be a mapping when provided.")
11493 elif isinstance(execution, dict):
11494 module_setup = execution.get(
"module_setup", [])
11495 if module_setup
is not None and not isinstance(module_setup, list):
11496 errors.append(f
" {cluster_path}: execution.module_setup must be a list of shell lines.")
11497 elif isinstance(module_setup, list):
11498 for i, line
in enumerate(module_setup):
11499 if not isinstance(line, str):
11500 errors.append(f
" {cluster_path}: execution.module_setup[{i}] must be a string.")
11502 launcher = execution.get(
"launcher")
11503 if launcher
is not None and not isinstance(launcher, str):
11504 errors.append(f
" {cluster_path}: execution.launcher must be a string when provided.")
11505 launcher_args = execution.get(
"launcher_args")
11506 if launcher_args
is not None and not isinstance(launcher_args, list):
11507 errors.append(f
" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
11508 elif isinstance(launcher_args, list):
11509 for i, token
in enumerate(launcher_args):
11510 if not isinstance(token, (str, int, float)):
11511 errors.append(f
" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
11514 f
" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
11515 "split whitespace-separated arguments into separate list items."
11517 if (launcher
is None or isinstance(launcher, str))
and (launcher_args
is None or isinstance(launcher_args, list)):
11520 except ValueError
as exc:
11521 errors.append(f
" {cluster_path}: {exc}.")
11523 extra_sbatch = execution.get(
"extra_sbatch")
11524 if extra_sbatch
is not None and not isinstance(extra_sbatch, (dict, list)):
11525 errors.append(f
" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
11527 walltime_guard = execution.get(
"walltime_guard")
11528 if walltime_guard
is not None and not isinstance(walltime_guard, dict):
11529 errors.append(f
" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
11530 elif isinstance(walltime_guard, dict):
11531 enabled = walltime_guard.get(
"enabled")
11532 if enabled
is not None and not isinstance(enabled, bool):
11533 errors.append(f
" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
11535 warmup_steps = walltime_guard.get(
"warmup_steps")
11536 if warmup_steps
is not None and (
not isinstance(warmup_steps, int)
or isinstance(warmup_steps, bool)
or warmup_steps <= 0):
11538 f
" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
11541 multiplier = walltime_guard.get(
"multiplier")
11542 if multiplier
is not None:
11543 if isinstance(multiplier, bool)
or not isinstance(multiplier, (int, float))
or multiplier <= 0.0:
11545 f
" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
11547 elif float(multiplier) > 5.0:
11549 f
" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
11552 min_seconds = walltime_guard.get(
"min_seconds")
11553 if min_seconds
is not None and (
11554 isinstance(min_seconds, bool)
or not isinstance(min_seconds, (int, float))
or float(min_seconds) <= 0.0
11557 f
" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
11560 estimator_alpha = walltime_guard.get(
"estimator_alpha")
11561 if estimator_alpha
is not None:
11562 if isinstance(estimator_alpha, bool)
or not isinstance(estimator_alpha, (int, float)):
11564 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
11566 elif float(estimator_alpha) <= 0.0
or float(estimator_alpha) > 1.0:
11568 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
11572 for warning
in warnings:
11573 print(f
"[WARNING] {warning}", file=sys.stderr)
11580 @brief Validate sweep/study specification from study.yml.
11581 @param[in] study_cfg Argument passed to `validate_study_config()`.
11582 @param[in] study_path Argument passed to `validate_study_config()`.
11583 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
11587 if not isinstance(study_cfg, dict)
or not study_cfg:
11588 errors.append(f
" {study_path}: study config is empty or not a valid YAML mapping.")
11591 base_cfgs = study_cfg.get(
"base_configs")
11592 if not isinstance(base_cfgs, dict):
11593 errors.append(f
" {study_path}: missing required mapping 'base_configs'.")
11595 for req
in (
"case",
"solver",
"monitor",
"post"):
11596 path_val = base_cfgs.get(req)
11597 if not path_val
or not isinstance(path_val, str):
11598 errors.append(f
" {study_path}: base_configs.{req} must be a path string.")
11599 elif not skip_base_file_check:
11601 if not os.path.isfile(resolved):
11602 errors.append(f
" {study_path}: base_configs.{req} does not exist: {resolved}")
11604 study_type = study_cfg.get(
"study_type")
11605 allowed_types = set(STUDY_TYPES)
11606 if study_type
not in allowed_types:
11608 f
" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
11611 parameters = study_cfg.get(
"parameters")
11612 parameter_sets = study_cfg.get(
"parameter_sets")
11613 allowed_roots = {
"case",
"solver",
"monitor",
"post"}
11614 if bool(parameters) == bool(parameter_sets):
11615 errors.append(f
" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
11616 elif parameter_sets:
11617 if not isinstance(parameter_sets, list)
or not parameter_sets:
11618 errors.append(f
" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
11620 for set_index, param_set
in enumerate(parameter_sets):
11621 if not isinstance(param_set, dict)
or not param_set:
11623 f
" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
11626 for key, value
in param_set.items():
11627 if not isinstance(key, str)
or "." not in key:
11629 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
11632 root = key.split(
".", 1)[0]
11633 if root
not in allowed_roots:
11635 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
11637 if isinstance(value, (dict, list)):
11639 f
" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
11642 if not isinstance(parameters, dict)
or not parameters:
11643 errors.append(f
" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
11645 for key, values
in parameters.items():
11646 if not isinstance(key, str)
or "." not in key:
11648 f
" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
11651 root = key.split(
".", 1)[0]
11652 if root
not in allowed_roots:
11654 f
" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
11656 if not isinstance(values, list)
or len(values) == 0:
11657 errors.append(f
" {study_path}: parameters.{key} must be a non-empty list.")
11659 metrics = study_cfg.get(
"metrics", [])
11660 if metrics
is not None and not isinstance(metrics, list):
11661 errors.append(f
" {study_path}: 'metrics' must be a list when provided.")
11662 elif isinstance(metrics, list):
11663 for i, metric
in enumerate(metrics):
11664 if isinstance(metric, str):
11666 if not isinstance(metric, dict):
11668 f
" {study_path}: metrics[{i}] must be a string or mapping."
11671 if "name" not in metric:
11672 errors.append(f
" {study_path}: metrics[{i}] missing required key 'name'.")
11673 if "source" not in metric:
11674 errors.append(f
" {study_path}: metrics[{i}] missing required key 'source'.")
11675 if str(metric.get(
"source",
"")).strip().lower()
in METRIC_SOURCE_KINDS[2:]:
11677 regex = metric.get(
"regex")
11679 has_group = isinstance(regex, str)
and re.compile(regex).groups >= 1
11684 f
" {study_path}: metrics[{i}].regex must be a valid regular expression "
11685 "with a capture group for the value when source is a log."
11687 for label_key
in (
"plot_label",
"label",
"units"):
11688 label_value = metric.get(label_key)
11689 if label_value
is not None and (
not isinstance(label_value, str)
or not label_value.strip()):
11691 f
" {study_path}: metrics[{i}].{label_key} must be a non-empty string when provided."
11694 plotting = study_cfg.get(
"plotting", {})
11695 if plotting
is not None and not isinstance(plotting, dict):
11696 errors.append(f
" {study_path}: 'plotting' must be a mapping when provided.")
11697 elif isinstance(plotting, dict):
11698 enabled = plotting.get(
"enabled")
11699 if enabled
is not None and not isinstance(enabled, bool):
11700 errors.append(f
" {study_path}: plotting.enabled must be boolean when provided.")
11701 output_format = plotting.get(
"output_format")
11702 if output_format
is not None and output_format
not in STUDY_PLOT_FORMATS:
11703 errors.append(f
" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
11705 execution = study_cfg.get(
"execution", {})
11706 if execution
is not None and not isinstance(execution, dict):
11707 errors.append(f
" {study_path}: 'execution' must be a mapping when provided.")
11708 elif isinstance(execution, dict):
11709 max_conc = execution.get(
"max_concurrent_array_tasks")
11710 if max_conc
is not None and (
not isinstance(max_conc, int)
or max_conc <= 0):
11712 f
" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
11720 @brief Set nested dictionary value, creating intermediate maps when needed.
11721 @param[in] container Argument passed to `_deep_set()`.
11722 @param[in] dotted_path Argument passed to `_deep_set()`.
11723 @param[in] value Argument passed to `_deep_set()`.
11725 keys = dotted_path.split(
".")
11726 current = container
11727 for key
in keys[:-1]:
11728 if key
not in current
or not isinstance(current[key], dict):
11730 current = current[key]
11731 current[keys[-1]] = value
11735 @brief Expand study parameter lists into cartesian-product combinations.
11736 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
11737 @return Value returned by `expand_parameter_matrix()`.
11739 param_keys =
list(parameters.keys())
11740 all_values = [parameters[k]
for k
in param_keys]
11742 for combo
in itertools.product(*all_values):
11743 combos.append(dict(zip(param_keys, combo)))
11749 @brief Expand either cartesian-study parameters or explicit parameter sets.
11750 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
11751 @return Value returned by `expand_study_parameter_combinations()`.
11753 parameter_sets = study_cfg.get(
"parameter_sets")
11755 return [dict(param_set)
for param_set
in parameter_sets]
11761 @brief Flatten grouped study overrides into scalar dotted-path columns.
11763 @details A grouped override such as `case.run_control: {dt_physical: ...}`
11764 materializes correctly in case YAML but is not a useful CSV cell or
11765 plot coordinate. Flattening preserves the actual varied variables.
11767 @param[in] parameters One expanded study parameter combination.
11768 @return Flat dotted-path-to-scalar mapping.
11772 def visit(prefix, value):
11774 @brief Recursively flatten one grouped override value.
11775 @param[in] prefix Current dotted parameter path.
11776 @param[in] value Scalar or nested mapping at the current path.
11778 if isinstance(value, dict):
11779 for child, child_value
in value.items():
11780 visit(f
"{prefix}.{child}" if prefix
else str(child), child_value)
11782 flattened[prefix] = value
11784 for key, value
in (parameters
or {}).items():
11785 visit(str(key), value)
11791 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
11792 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
11793 @return Value returned by `get_study_parameter_keys()`.
11795 parameters = study_cfg.get(
"parameters")
11796 if isinstance(parameters, dict)
and parameters:
11798 for key, candidates
in parameters.items():
11799 candidate_dicts = [value
for value
in candidates
if isinstance(value, dict)]
if isinstance(candidates, list)
else []
11801 for value
in candidate_dicts:
11803 if flat_key
not in expanded:
11804 expanded.append(flat_key)
11805 for flat_key
in expanded
or [key]:
11806 if flat_key
not in keys:
11807 keys.append(flat_key)
11811 parameter_sets = study_cfg.get(
"parameter_sets")
or []
11812 for param_set
in parameter_sets:
11813 if not isinstance(param_set, dict):
11816 if key
not in keys:
11823 @brief Return cluster total tasks.
11824 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
11825 @return Value returned by `get_cluster_total_tasks()`.
11827 resources = cluster_cfg.get(
"resources", {})
11828 return int(resources.get(
"nodes", 1)) * int(resources.get(
"ntasks_per_node", 1))
11832 @brief Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
11833 @param[in] ext Argument passed to `normalize_extension()`.
11834 @return Value returned by `normalize_extension()`.
11838 return str(ext).strip().lstrip(
".")
11842 @brief Resolve how to invoke this conductor again from a batch script.
11844 @details Prefers the `bin/picurv` wrapper, which selects the managed Python
11845 environment a cluster node needs, and falls back to the running
11846 interpreter with the package entry point when that wrapper is absent.
11848 @return Argv prefix that re-invokes the conductor.
11850 wrapper = os.path.join(PACKAGE_PROJECT_ROOT,
"bin",
"picurv")
11851 if os.path.isfile(wrapper)
and os.access(wrapper, os.X_OK):
11853 return [sys.executable, os.path.join(PACKAGE_PROJECT_ROOT,
"picurv_cli",
"picurv")]
11857 recompute: bool =
False) -> list:
11859 @brief Build the batch-script step that measures spectra after the field stage.
11861 @details Spectra are a serial pass over committed checkpoints, so the command is
11862 returned bare rather than wrapped in the MPI launcher: run under `srun`
11863 with the post stage's task count it would become one identical copy per
11864 task, each writing the same files.
11866 @param[in] run_dir Run directory the batch job operates on.
11867 @param[in] post_path Post recipe path, reachable from the compute node.
11868 @param[in] post_cfg Effective post configuration.
11869 @param[in] recompute Re-measure steps whose spectra are still valid.
11870 @return Argv list, or an empty list when the recipe requests no spectra.
11877 if not spectra[
"tasks"]:
11880 "run",
"--post-process",
"--only",
"spectra",
11881 "--run-dir", os.path.abspath(run_dir),
11882 "--post", os.path.abspath(post_path),
11883 ] + ([
"--recompute"]
if recompute
else [])
11888 @brief The lib/ directory of the PETSc an executable was built against, from its stamp.
11889 @details Both PETSc index widths name their library libpetsc.so.MAJOR.MINOR, so the loader
11890 takes whichever comes first on LD_LIBRARY_PATH; launching with this directory first
11891 gives each executable its own PETSc whatever modules the job loaded.
11892 @param[in] executable_path Path to `simulator`, `postprocessor` or their 64-bit pair.
11893 @return Absolute directory, or None when the stamp does not name one.
11896 if not petsc
or not petsc.get(
"dir"):
11898 library_dir = os.path.join(petsc[
"dir"], petsc.get(
"arch")
or "",
"lib")
11899 return os.path.normpath(library_dir)
if os.path.isdir(library_dir)
else None
11904 @brief Shell lines putting one PETSc's lib/ first on LD_LIBRARY_PATH, after module setup.
11905 @param[in] library_dir From `executable_petsc_library_dir()`, or None for no change.
11906 @return Script lines.
11908 if not library_dir:
11910 return [f
'export LD_LIBRARY_PATH={shlex.quote(library_dir)}"${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}"']
11914 pinned: bool =
False) -> list:
11916 @brief Shell lines that compare an executable's reported identity with the staged one.
11918 @details A job resolves its executable when it starts, which can be long after it
11919 was staged. The installation's `bin/` may have been rebuilt in between, and
11920 a job that silently runs the new build produces checkpoints that disagree
11921 with the run's manifest. The lines run after module setup, because
11922 `--version` still needs the executable's shared libraries, and before the
11923 launcher, so a mismatch stops the job before any rank starts. When staging
11924 could not read an identity there is nothing to compare, so the reported one
11926 @param[in] executable Executable path, emitted verbatim when it is a shell variable.
11927 @param[in] expected_version_line Exact `--version` output captured at staging, or None.
11928 @param[in] pinned Whether the executable is the run's own pinned copy.
11929 @return Script lines.
11931 token = executable
if executable.startswith(
"$")
else shlex.quote(executable)
11933 f
'PICURV_EXECUTABLE_IDENTITY=$({token} --version 2>&1) || {{ '
11934 f
'echo "[FATAL] {executable} --version failed: ${{PICURV_EXECUTABLE_IDENTITY}}" >&2; exit 1; }}',
11935 'echo "[$(date)] Executable identity: ${PICURV_EXECUTABLE_IDENTITY}"',
11937 if not expected_version_line:
11938 lines.append(
'echo "[$(date)] No identity was readable at staging, so none is compared."')
11941 "The run's pinned copy was replaced after staging; restore it or re-pin the run."
11943 "It was rebuilt after staging; restage the job, or stage with --pin-executables so "
11944 "the run keeps its own copy."
11947 f
"PICURV_STAGED_IDENTITY={shlex.quote(expected_version_line)}",
11948 'if [ "${PICURV_EXECUTABLE_IDENTITY}" != "${PICURV_STAGED_IDENTITY}" ]; then',
11949 f
' echo "[FATAL] {executable} reports ${{PICURV_EXECUTABLE_IDENTITY}}, but this job was '
11950 f
'staged against ${{PICURV_STAGED_IDENTITY}}. {remedy}" >&2',
11964 stderr_path: str =
None,
11965 env_vars: dict =
None,
11966 shell_env_vars: dict =
None,
11967 array_spec: str =
None,
11968 follow_commands: list =
None,
11969 identity_check:
"tuple | None" =
None,
11972 @brief Render a Slurm batch script for a single command.
11973 @param[in] script_path Argument passed to `render_slurm_script()`.
11974 @param[in] job_name Argument passed to `render_slurm_script()`.
11975 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
11976 @param[in] command Argument passed to `render_slurm_script()`.
11977 @param[in] workdir Argument passed to `render_slurm_script()`.
11978 @param[in] stdout_path Argument passed to `render_slurm_script()`.
11979 @param[in] stderr_path Argument passed to `render_slurm_script()`.
11980 @param[in] env_vars Argument passed to `render_slurm_script()`.
11981 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
11982 @param[in] array_spec Argument passed to `render_slurm_script()`.
11983 @param[in] follow_commands Commands to run after the launched one, each an argv
11984 list. They run in the batch shell rather than under the
11985 MPI launcher, so a serial step does not become one copy
11986 per task. Supplying any of them drops the `exec`.
11987 @param[in] identity_check `(executable, expected --version line, pinned)` checked
11988 before launch; see `executable_identity_check_lines()`.
11990 resources = cluster_cfg.get(
"resources", {})
11991 notifications = cluster_cfg.get(
"notifications", {})
or {}
11992 execution = cluster_cfg.get(
"execution", {})
or {}
11993 extra_sbatch = execution.get(
"extra_sbatch")
11994 module_setup = execution.get(
"module_setup", [])
or []
11996 if stderr_path
is None:
11997 stderr_path = stdout_path.replace(
".out",
".err")
12001 f
"#SBATCH --job-name={job_name}",
12002 f
"#SBATCH --nodes={resources['nodes']}",
12003 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
12004 f
"#SBATCH --mem={resources['mem']}",
12005 f
"#SBATCH --time={resources['time']}",
12006 f
"#SBATCH --output={stdout_path}",
12007 f
"#SBATCH --error={stderr_path}",
12008 f
"#SBATCH --account={resources['account']}",
12010 partition = resources.get(
"partition")
12012 lines.append(f
"#SBATCH --partition={partition}")
12014 lines.append(f
"#SBATCH --array={array_spec}")
12015 mail_user = notifications.get(
"mail_user")
12016 mail_type = notifications.get(
"mail_type")
12018 lines.append(f
"#SBATCH --mail-user={mail_user}")
12020 lines.append(f
"#SBATCH --mail-type={mail_type}")
12022 if isinstance(extra_sbatch, dict):
12023 for key, value
in extra_sbatch.items():
12025 if not flag.startswith(
"--"):
12027 if isinstance(value, bool):
12029 lines.append(f
"#SBATCH {flag}")
12030 elif value
is not None:
12031 lines.append(f
"#SBATCH {flag}={value}")
12032 elif isinstance(extra_sbatch, list):
12033 for token
in extra_sbatch:
12034 lines.append(f
"#SBATCH {token}")
12039 "set -euo pipefail",
12041 f
"cd {shlex.quote(workdir)}",
12042 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
12043 'echo "[$(date)] Working directory: $PWD"',
12048 for key, value
in shell_env_vars.items():
12049 lines.append(f
"export {key}={value}")
12051 for setup_line
in module_setup:
12052 lines.append(str(setup_line))
12055 for key, value
in env_vars.items():
12056 lines.append(f
"export {key}={shlex.quote(str(value))}")
12062 cmd =
" ".join(shlex.quote(str(tok))
for tok
in command)
12063 if follow_commands:
12068 for follow
in follow_commands:
12069 lines.append(
" ".join(shlex.quote(str(tok))
for tok
in follow))
12071 lines.append(f
"exec {cmd}")
12073 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
12074 with open(script_path,
"w")
as f:
12075 f.write(
"\n".join(lines) +
"\n")
12076 os.chmod(script_path, 0o755)
12079 launcher:
"str | None",
12080 launcher_args:
"list | None" =
None,
12081 label: str =
"launcher",
12082) ->
"tuple[str | None, list[str]]":
12084 @brief Canonicalize launcher config into executable token plus argv-style flags.
12085 @param[in] launcher Argument passed to `split_launcher_tokens()`.
12086 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
12087 @param[in] label Argument passed to `split_launcher_tokens()`.
12088 @return Value returned by `split_launcher_tokens()`.
12090 normalized_args = [str(x)
for x
in (launcher_args
or [])]
12092 if launcher
is None:
12093 return None, normalized_args
12096 launcher_tokens = shlex.split(str(launcher))
12097 except ValueError
as exc:
12098 raise ValueError(f
"{label} is not shell-parseable: {exc}")
from exc
12100 if not launcher_tokens:
12101 return None, normalized_args
12103 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
12108 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
12109 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
12110 @return Value returned by `normalize_cluster_launcher()`.
12113 execution.get(
"launcher"),
12114 execution.get(
"launcher_args")
or [],
12115 label=
"execution.launcher",
12121 @brief Remove explicit MPI task-count flags from known launchers.
12122 @param[in] launcher_name Basename-normalized launcher executable.
12123 @param[in] launcher_args Launcher argument list.
12124 @return Filtered launcher arguments with explicit size flags removed.
12128 while idx < len(launcher_args):
12129 token = str(launcher_args[idx])
12131 if launcher_name ==
"srun":
12132 if token
in {
"-n",
"--ntasks"}:
12135 if token.startswith(
"--ntasks="):
12138 elif launcher_name
in {
"mpiexec",
"mpirun"}:
12139 if token
in {
"-n",
"-np"}:
12142 if token.startswith(
"-n=")
or token.startswith(
"-np="):
12146 filtered.append(token)
12154 executable_args: list,
12156 config_search_anchor: str =
None,
12157 allow_single_rank_launcher_override: bool =
False,
12158 force_num_procs:
"int | None" =
None,
12161 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
12162 @param[in] executable Argument passed to `build_local_launch_command()`.
12163 @param[in] executable_args Argument passed to `build_local_launch_command()`.
12164 @param[in] num_procs Argument passed to `build_local_launch_command()`.
12165 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
12166 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
12167 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
12168 @return Value returned by `build_local_launch_command()`.
12170 target_num_procs = force_num_procs
if force_num_procs
is not None else num_procs
12171 command = [executable] + executable_args
12172 if target_num_procs <= 1
and not allow_single_rank_launcher_override:
12175 launcher_override = os.environ.get(
"PICURV_MPI_LAUNCHER")
12176 if launcher_override
is None:
12177 launcher_override = os.environ.get(
"MPI_LAUNCHER")
12180 if launcher_override
is not None:
12181 explicit_launcher_config =
True
12184 label=
"local MPI launcher override",
12189 configured_launcher = local_execution.get(
"launcher")
12190 configured_args = local_execution.get(
"launcher_args")
or []
12191 explicit_launcher_config = configured_launcher
is not None or bool(configured_args)
12192 if target_num_procs <= 1
and not explicit_launcher_config:
12195 configured_launcher
if configured_launcher
is not None else "mpiexec",
12197 label=
"local_execution.launcher",
12199 except ValueError
as exc:
12200 print(f
"[FATAL] {exc}", file=sys.stderr)
12206 launcher_name = os.path.basename(launcher).lower()
12207 if force_num_procs
is not None:
12209 prefix = [launcher] + launcher_args
12211 if launcher_name ==
"srun":
12212 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
12214 prefix += [
"-n", str(target_num_procs)]
12215 elif launcher_name
in {
"mpiexec",
"mpirun"}:
12216 has_n = any(token
in {
"-n",
"-np"}
for token
in launcher_args)
12218 prefix += [
"-n", str(target_num_procs)]
12220 return prefix + command
12224 @brief The inputs a run's geometry is derived from, to compare across a restart.
12225 @details The solver compares the checkpoint's geometry, which it hashes from the built
12226 grid, only after the job has started. These inputs determine that geometry
12227 within one build, so a difference is worth saying at staging: a staged
12228 `inputs/grid/grid.run` (file and generated grids, already non-dimensional)
12229 by content, otherwise the grid section without its rank layout and the
12230 length scale; and the periodic faces of every block.
12231 @param[in] run_dir Run whose staged grid is read.
12232 @param[in] case_cfg That run's case configuration.
12233 @return Mapping of input name to a comparable value.
12235 staged = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
12236 grid = {key: value
for key, value
in (case_cfg.get(
"grid")
or {}).items()
12237 if not str(key).startswith(
"da_processors_")}
12239 periodic = [sorted(bc[
"face"]
for bc
in block
if bc[
"type"] ==
"PERIODIC")
12241 except (ValueError, KeyError, TypeError):
12243 inputs = {
"periodic faces": periodic}
12244 if os.path.isfile(staged):
12247 inputs[
"grid"] = grid
12248 inputs[
"length_ref"] = ((case_cfg.get(
"properties")
or {}).get(
"scaling")
or {}).get(
"length_ref")
12254 @brief Warn at staging when a restart's grid inputs differ from its source run's.
12255 @param[in] source_inputs restart_grid_inputs() of the run that wrote the checkpoint.
12256 @param[in] branch_inputs restart_grid_inputs() of the staged restart.
12257 @return Names of the inputs that differ.
12259 changed = sorted(name
for name
in set(source_inputs) | set(branch_inputs)
12260 if source_inputs.get(name) != branch_inputs.get(name))
12262 print(f
"[WARNING] This restart's grid inputs differ from the source run's ({', '.join(changed)}). "
12263 "The solver refuses a checkpoint whose geometry differs, but only once the job has "
12264 "started and built the grid.", file=sys.stderr)
12269 config_search_anchor: str =
None):
12271 @brief Ask the simulator whether a staged restart's carried statistics windows can continue.
12272 @details Runs the simulator's restart check on one rank. It parses the run's control
12273 file and compares each window with the source checkpoint's manifest exactly
12274 as the restart will, but builds no grid, so it costs about a second and runs
12275 on a login node whatever the production rank count. Passing it does not
12276 replace the full check at solver start, which also compares geometry.
12277 @param[in] solver_exe Simulator executable the run will launch.
12278 @param[in] solver_args Simulator arguments the run will launch with.
12279 @param[in] run_dir Staged run directory, the simulator's working directory.
12280 @param[in] config_search_anchor Path from which the local launcher configuration is found.
12281 @return None when the windows can continue, else the simulator's own reason.
12284 solver_exe, solver_args + [
"-restart_check_only",
"true"], 1,
12285 config_search_anchor=config_search_anchor,
12286 allow_single_rank_launcher_override=
True, force_num_procs=1,
12289 if result.returncode == 0:
12290 print(
"[INFO] Statistics continuation check passed.")
12293 messages = [line.split(
"PETSC ERROR:", 1)[1].strip()
12294 for line
in (result.stdout + result.stderr).splitlines()
if "PETSC ERROR:" in line]
12295 messages = [m
for m
in messages
if m
and not m.startswith(
"-")]
12296 return (messages[1]
if len(messages) > 1
else messages[0]
if messages
12297 else (result.stderr
or result.stdout).strip()
or f
"exit status {result.returncode}")
12302 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
12303 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
12304 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
12305 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
12306 @return Value returned by `resolve_cluster_execution()`.
12310 execution = cluster_cfg.get(
"execution", {})
or {}
12311 cluster_override = {
12312 "launcher": execution.get(
"launcher")
if "launcher" in execution
else None,
12313 "launcher_args": execution.get(
"launcher_args")
if "launcher_args" in execution
else None,
12321 executable_args: list,
12322 config_search_anchor: str =
None,
12323 extra_search_anchors=
None,
12324 force_num_procs:
"int | None" =
None,
12327 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
12328 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
12329 @param[in] executable Argument passed to `build_cluster_launch_command()`.
12330 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
12331 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
12332 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
12333 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
12334 @return Value returned by `build_cluster_launch_command()`.
12339 config_search_anchor=config_search_anchor,
12340 extra_search_anchors=extra_search_anchors,
12343 execution.get(
"launcher")
if execution.get(
"launcher")
is not None else "srun",
12344 execution.get(
"launcher_args")
or [],
12345 label=
"cluster execution launcher",
12347 except ValueError
as exc:
12348 print(f
"[FATAL] {exc}", file=sys.stderr)
12352 launcher_name = launcher.lower()
if launcher
else ""
12353 if force_num_procs
is not None:
12356 if launcher
and launcher_name ==
"srun":
12357 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
12358 cmd = [
"srun"] + launcher_args
12360 cmd += [
"-n", str(ntasks)]
12361 return cmd + [executable] + executable_args
12363 if launcher
and launcher_name ==
"mpirun":
12364 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
12365 cmd = [
"mpirun"] + launcher_args
12367 cmd += [
"-np", str(ntasks)]
12368 return cmd + [executable] + executable_args
12370 if launcher
and launcher_name ==
"mpiexec":
12371 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
12372 cmd = [
"mpiexec"] + launcher_args
12374 cmd += [
"-np", str(ntasks)]
12375 return cmd + [executable] + executable_args
12380 cmd.append(str(launcher))
12381 cmd += launcher_args
12382 cmd += [executable] + executable_args
12387 @brief Extract numeric job id from standard sbatch output.
12388 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
12389 @return Value returned by `parse_slurm_job_id()`.
12391 match = re.search(
r"Submitted batch job\s+(\d+)", sbatch_output
or "")
12392 return match.group(1)
if match
else None
12395_SLURM_FAILED_END_STATES = {
12396 "FAILED",
"CANCELLED",
"TIMEOUT",
"OUT_OF_MEMORY",
"NODE_FAIL",
"PREEMPTED",
12397 "BOOT_FAIL",
"DEADLINE",
12403 @brief Whether a recorded Slurm job (or array) is still queued, and how it ended if not.
12404 @details Slurm forgets a finished job after its MinJobAge, and a dependency on a job it
12405 no longer knows is rejected, so a stage submitted long after the job it waits
12406 for must not depend on it. `squeue` answers for live jobs; `sacct` for ended ones.
12407 @param[in] job_id Recorded job id; for an array, the array's id covers every task.
12408 @return `("active", state)`, `("completed", "COMPLETED")`, `("failed", state)`,
12409 `("unknown", state-or-None)` when the job is not queued but `sacct` cannot say how
12410 it ended, or `("unchecked", None)` where `squeue` is unavailable.
12412 squeue = shutil.which(
"squeue")
12414 return "unchecked",
None
12415 result = subprocess.run([squeue,
"-h",
"-j", str(job_id),
"-o",
"%T"],
12416 text=
True, capture_output=
True, check=
False)
12417 live = [line.strip()
for line
in (result.stdout
or "").splitlines()
if line.strip()]
12418 if result.returncode == 0
and live:
12419 return "active", live[0]
12420 sacct = shutil.which(
"sacct")
12422 return "unknown",
None
12423 result = subprocess.run([sacct,
"-n",
"-X",
"-P",
"-j", str(job_id),
"-o",
"State"],
12424 text=
True, capture_output=
True, check=
False)
12426 states = [line.split()[0]
for line
in (result.stdout
or "").splitlines()
if line.strip()]
12427 if result.returncode != 0
or not states:
12428 return "unknown",
None
12429 failed = [state
for state
in states
if state
in _SLURM_FAILED_END_STATES]
12431 return "failed", failed[0]
12432 if all(state ==
"COMPLETED" for state
in states):
12433 return "completed",
"COMPLETED"
12435 return "unknown", states[0]
12438def submit_sbatch(script_path: str, dependency: str =
None, dependency_type: str =
"afterok") -> dict:
12440 @brief Submit sbatch script and return submission metadata.
12441 @param[in] script_path Argument passed to `submit_sbatch()`.
12442 @param[in] dependency Argument passed to `submit_sbatch()`.
12443 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
12444 @return Value returned by `submit_sbatch()`.
12448 cmd.append(f
"--dependency={dependency_type}:{dependency}")
12449 cmd.append(script_path)
12450 result = subprocess.run(cmd, text=
True, capture_output=
True, check=
False)
12453 "returncode": result.returncode,
12454 "stdout": (result.stdout
or "").strip(),
12455 "stderr": (result.stderr
or "").strip(),
12456 "script": script_path,
12458 if result.returncode != 0:
12459 print(f
"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
12460 sys.exit(result.returncode)
12462 if not metadata[
"job_id"]:
12464 f
"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
12473 @brief Prints validation errors and exits.
12474 @param[in] errors List of error message strings.
12476 print(f
"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
12477 for raw_error
in errors:
12483 "\nHint: See examples/master_template/ for valid config structure and "
12484 "docs/pages/14_Config_Contract.md for key-level contract details.",
12492 @brief Creates a standard header block for all generated files.
12493 @param[in] run_id The unique identifier for the current simulation run.
12494 @param[in] source_files A dictionary of source profile files used.
12495 @return A formatted string containing the header.
12498 "# ==============================================================================",
12499 "# AUTO-GENERATED CONFIGURATION FILE",
12500 "# ------------------------------------------------------------------------------",
12501 f
"# Run ID: {run_id}",
12502 f
"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
12504 "# Source Configuration:"
12506 for name, path
in source_files.items():
12507 header_parts.append(f
"# - {name:<12}: {os.path.basename(path)}")
12508 header_parts.extend([
12510 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
12511 "# ==============================================================================\n"
12513 return "\n".join(header_parts)
12516 filename: str, header_sources: dict,
12517 config_dir: str =
None) -> str:
12519 @brief Generic function to create a file containing a simple list of strings.
12520 @param[in] run_dir The path to the main run directory.
12521 @param[in] run_id The unique identifier for the run.
12522 @param[in] cfg The dictionary containing the configuration data.
12523 @param[in] section The top-level key in the cfg dictionary.
12524 @param[in] key The second-level key whose value is the list of strings.
12525 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
12526 @param[in] header_sources A dictionary of source files for the header.
12527 @param[in] config_dir Optional configuration revision directory.
12528 @return The absolute path to the generated file.
12530 print(f
"[INFO] Generating {filename}...")
12531 config_dir = config_dir
or os.path.join(run_dir,
"config")
12532 os.makedirs(config_dir, exist_ok=
True)
12533 file_path = os.path.join(config_dir, filename)
12536 items = cfg.get(section, {}).get(key, [])
12537 lines.extend(items)
12539 with open(file_path,
"w")
as f: f.write(
"\n".join(lines))
12540 print(f
"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
12541 return os.path.abspath(file_path)
12546 @brief Translate renamed solver functions in a monitor.yml function list.
12547 @param[in] names Function names as written in monitor.yml.
12548 @param[in] field_name YAML path of the list, used in warnings.
12549 @return The list with renamed functions replaced and removed ones dropped.
12550 @details Logging and profiling select functions by exact name, so a stale name
12551 silently selects nothing. Renamed functions are mapped to their current
12552 names and removed ones dropped, each with a warning.
12555 for name
in names
or []:
12556 if name
in RENAMED_MONITOR_FUNCTIONS:
12557 current = RENAMED_MONITOR_FUNCTIONS[name]
12558 print(f
"[WARNING] {field_name}: '{name}' was renamed to '{current}'; using '{current}'.",
12561 elif name
in REMOVED_MONITOR_FUNCTIONS:
12562 print(f
"[WARNING] {field_name}: '{name}' no longer exists (removed by the Poisson "
12563 "rewrite, 0d34592); dropping it.", file=sys.stderr)
12565 if name
not in normalized:
12566 normalized.append(name)
12572 @brief Return True when logging.enabled_functions contains at least one entry.
12573 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
12574 @return Value returned by `has_explicit_monitor_whitelist()`.
12576 items = monitor_cfg.get(
"logging", {}).get(
"enabled_functions", [])
12581RUNTIME_MEMORY_FILE =
"Runtime_Memory.csv"
12582PROFILING_TIMESTEP_FILE =
"Profiling_Timestep_Summary.csv"
12587 @brief Resolve profiling reporting config from monitor.yml.
12588 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
12589 @return Value returned by `resolve_profiling_config()`.
12591 profiling_cfg = monitor_cfg.get(
"profiling", {})
or {}
12592 timestep_cfg = profiling_cfg.get(
"timestep_output")
12593 final_cfg = profiling_cfg.get(
"final_summary")
12595 if timestep_cfg
is None:
12599 if not isinstance(timestep_cfg, dict):
12600 raise ValueError(
"monitor.profiling.timestep_output must be a mapping when provided.")
12601 mode = str(timestep_cfg.get(
"mode",
"off")).lower()
12602 functions = timestep_cfg.get(
"functions", [])
12604 if mode
not in PROFILING_TIMESTEP_MODES:
12605 raise ValueError(
"monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
12606 if functions
is None:
12608 if not isinstance(functions, list):
12609 raise ValueError(
"monitor.profiling.timestep_output.functions must be a list of function names.")
12610 if not all(isinstance(item, str)
and item.strip()
for item
in functions):
12611 raise ValueError(
"monitor.profiling.timestep_output.functions entries must be non-empty strings.")
12612 if mode ==
"selected" and not functions:
12613 raise ValueError(
"monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
12614 if mode !=
"selected" and functions:
12615 raise ValueError(
"monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
12617 if mode ==
"selected" and not functions:
12618 raise ValueError(
"monitor.profiling.timestep_output.functions lists only functions that no longer exist.")
12620 if final_cfg
is None:
12621 final_enabled =
True
12622 elif isinstance(final_cfg, dict):
12623 final_enabled = bool(final_cfg.get(
"enabled",
True))
12625 raise ValueError(
"monitor.profiling.final_summary must be a mapping when provided.")
12628 helpers = profiling_cfg.get(
"helpers", [])
12629 if helpers
is None:
12631 if isinstance(helpers, str):
12632 if helpers.strip().lower() !=
"all":
12633 raise ValueError(
"monitor.profiling.helpers must be 'all' or a list of function names.")
12635 elif not isinstance(helpers, list)
or not all(isinstance(item, str)
and item.strip()
for item
in helpers):
12636 raise ValueError(
"monitor.profiling.helpers must be 'all' or a list of non-empty function names.")
12637 elif helpers != [
"all"]:
12639 if any(
"," in item
or item.strip() != item
for item
in helpers):
12640 raise ValueError(
"monitor.profiling.helpers names must not contain commas or surrounding spaces.")
12644 "functions": functions,
12645 "final_summary_enabled": final_enabled,
12646 "helpers": helpers,
12650DIAGNOSTICS_PETSC_KEYS = {
12656 "malloc_view_threshold",
12669 @brief Validate PETSc info logging configuration.
12670 @param[in] value Boolean or structured PETSc info configuration.
12671 @return Normalized enabled/class-filter mapping.
12673 if isinstance(value, bool):
12674 return {
"enabled": value,
"classes": []}
12676 return {
"enabled":
False,
"classes": []}
12677 if not isinstance(value, dict):
12678 raise ValueError(
"monitor.diagnostics.petsc.info must be boolean, null, or a mapping.")
12679 unknown = sorted(set(value) - {
"enabled",
"classes"})
12681 raise ValueError(f
"monitor.diagnostics.petsc.info has unsupported key(s): {unknown}.")
12682 enabled = value.get(
"enabled",
True)
12683 classes = value.get(
"classes", [])
12684 if not isinstance(enabled, bool):
12685 raise ValueError(
"monitor.diagnostics.petsc.info.enabled must be boolean.")
12686 if not isinstance(classes, list)
or not all(
12687 isinstance(item, str)
and item.strip()
and "," not in item
and ":" not in item
12688 for item
in classes
12691 "monitor.diagnostics.petsc.info.classes must be a list of non-empty PETSc class names."
12693 return {
"enabled": enabled,
"classes": [item.strip()
for item
in classes]}
12698 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
12699 @param[in] value Candidate value.
12700 @param[in] key Diagnostics key used in error messages.
12701 @return Normalized value.
12703 if isinstance(value, bool)
or value
is None:
12705 if isinstance(value, str)
and value.strip():
12706 return value.strip()
12707 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
12712 @brief Validate a diagnostics boolean value.
12713 @param[in] value Candidate value.
12714 @param[in] key Diagnostics key used in error messages.
12715 @return Boolean value.
12717 if isinstance(value, bool):
12719 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean.")
12724 @brief Validate a diagnostics value that can be false, true, or "all".
12725 @param[in] value Candidate value.
12726 @param[in] key Diagnostics key used in error messages.
12727 @return Normalized value.
12729 if isinstance(value, bool)
or value
is None:
12731 if isinstance(value, str)
and value.strip().lower() ==
"all":
12733 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
12738 @brief Return an absolute run-local diagnostics file path.
12739 @param[in] run_dir Run directory.
12740 @param[in] filename Diagnostics filename.
12741 @return Absolute diagnostics path under the run logs directory.
12743 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS[
"logs"], filename))
12748 @brief Resolve true/string diagnostics values to a concrete file path.
12749 @param[in] value Boolean/string diagnostics value.
12750 @param[in] run_dir Run directory.
12751 @param[in] default_filename Default file name when value is true.
12752 @return False, or an absolute/explicit path string.
12756 if isinstance(value, str):
12757 if os.path.isabs(value)
or value.startswith(
":"):
12759 return os.path.abspath(os.path.join(run_dir,
"logs", value))
12765 @brief Resolve monitor diagnostics config and default run-local log paths.
12766 @param[in] monitor_cfg Parsed monitor.yml mapping.
12767 @param[in] run_dir Optional run directory for default artifact paths.
12768 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
12769 @return Normalized diagnostics config.
12771 diagnostics_cfg = (monitor_cfg.get(
"diagnostics", {})
or {})
if isinstance(monitor_cfg, dict)
else {}
12772 if not isinstance(diagnostics_cfg, dict):
12773 raise ValueError(
"monitor.diagnostics must be a mapping when provided.")
12775 petsc_raw = diagnostics_cfg.get(
"petsc", {})
or {}
12776 if not isinstance(petsc_raw, dict):
12777 raise ValueError(
"monitor.diagnostics.petsc must be a mapping when provided.")
12778 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
12780 raise ValueError(f
"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
12784 "malloc_debug":
_diagnostic_bool(petsc_raw.get(
"malloc_debug",
False),
"malloc_debug"),
12785 "malloc_test":
_diagnostic_bool(petsc_raw.get(
"malloc_test",
False),
"malloc_test"),
12786 "malloc_dump":
_diagnostic_bool(petsc_raw.get(
"malloc_dump",
False),
"malloc_dump"),
12788 "malloc_view_threshold": petsc_raw.get(
"malloc_view_threshold"),
12789 "memory_view":
_diagnostic_bool(petsc_raw.get(
"memory_view",
False),
"memory_view"),
12791 "log_view_memory":
_diagnostic_bool(petsc_raw.get(
"log_view_memory",
False),
"log_view_memory"),
12795 "options_left": petsc_raw.get(
"options_left"),
12797 if petsc[
"malloc_view_threshold"]
is not None and not isinstance(petsc[
"malloc_view_threshold"], (int, float)):
12798 raise ValueError(
"monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
12799 if petsc[
"options_left"]
is not None and not isinstance(petsc[
"options_left"], bool):
12800 raise ValueError(
"monitor.diagnostics.petsc.options_left must be boolean or null.")
12802 memory_raw = diagnostics_cfg.get(
"runtime_memory_log", {})
or {}
12803 if not isinstance(memory_raw, dict):
12804 raise ValueError(
"monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
12806 memory_unknown = sorted(set(memory_raw.keys()) - {
"enabled",
"file"})
12808 raise ValueError(f
"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
12809 memory_enabled = memory_raw.get(
"enabled",
True)
12810 if not isinstance(memory_enabled, bool):
12811 raise ValueError(
"monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
12813 resolved_petsc = dict(petsc)
12816 suffix =
"PostProcessor" if stage_label ==
"PostProcessor" else "Solver"
12817 resolved_petsc[
"info"] =
False
12819 "malloc_view": f
"PETSc_MallocView_{suffix}.log",
12820 "log_view": f
"PETSc_LogView_{suffix}.log",
12821 "log_trace": f
"PETSc_LogTrace_{suffix}.log",
12823 if petsc[
"info"][
"enabled"]:
12825 classes = petsc[
"info"][
"classes"]
12826 resolved_petsc[
"info"] = info_path + (f
":{','.join(classes)}" if classes
else "")
12828 artifacts.append(f
"{info_path}.*")
12829 for key, default_name
in defaults.items():
12831 if key ==
"log_view" and resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
12832 resolved_value = f
":{resolved_value}"
12833 resolved_petsc[key] = resolved_value
12834 if resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
12835 artifacts.append(resolved_value)
12836 elif resolved_value
and isinstance(resolved_value, str)
and resolved_value.startswith(
":"):
12837 artifacts.append(resolved_value[1:])
12840 os.path.abspath(os.path.join(
12841 run_dir, CANONICAL_RUN_PATHS[
"logs"], RUNTIME_MEMORY_FILE
12846 "petsc": resolved_petsc,
12847 "runtime_memory_log": {
"enabled": memory_enabled},
12848 "artifacts": artifacts,
12854 @brief Build PETSc diagnostics command-line arguments for a run stage.
12855 @param[in] monitor_cfg Parsed monitor.yml mapping.
12856 @param[in] run_dir Run directory used to resolve default diagnostics files.
12857 @param[in] stage_label Stage label for default output names.
12858 @return List of executable arguments.
12861 petsc = diagnostics[
"petsc"]
12864 args.extend([
"-info", str(petsc[
"info"])])
12865 if petsc[
"malloc_debug"]:
12866 args.append(
"-malloc_debug")
12867 if petsc[
"malloc_test"]:
12868 args.append(
"-malloc_test")
12870 (
"malloc_dump",
"-malloc_dump"),
12871 (
"malloc_view",
"-malloc_view"),
12872 (
"memory_view",
"-memory_view"),
12873 (
"log_view",
"-log_view"),
12874 (
"log_trace",
"-log_trace"),
12875 (
"objects_dump",
"-objects_dump"),
12877 value = petsc.get(key)
12881 args.extend([flag, str(value)])
12882 if petsc[
"malloc_view_threshold"]
is not None:
12883 args.extend([
"-malloc_view_threshold", str(petsc[
"malloc_view_threshold"])])
12884 if petsc[
"log_view_memory"]:
12885 args.append(
"-log_view_memory")
12886 if petsc[
"log_all"]:
12887 args.append(
"-log_all")
12888 if petsc[
"options_left"]
is not None:
12889 args.extend([
"-options_left",
"true" if petsc[
"options_left"]
else "false"])
12890 elif stage_label ==
"PostProcessor":
12893 args.extend([
"-options_left",
"false"])
12899 @brief Report whether the subsystem a statistics field depends on is active.
12900 @param[in] case_cfg Parsed case configuration.
12901 @param[in] requirement Subsystem key from `STATISTICS_ELIGIBLE_FIELDS`, or None.
12902 @return Value returned by `_statistics_subsystem_available()`.
12904 if requirement
is None:
12906 physics = ((case_cfg
or {}).get(
"models", {})
or {}).get(
"physics", {})
or {}
12907 if requirement ==
"particles":
12908 particles = physics.get(
"particles", {})
or {}
12910 return int(particles.get(
"count", 0)
or 0) > 0
12911 except (TypeError, ValueError):
12913 turbulence = physics.get(
"turbulence", {})
or {}
12914 les_on = bool((turbulence.get(
"les", {})
or {}).get(
"enabled",
False))
12915 if requirement ==
"les":
12917 if requirement ==
"turbulence":
12924 @brief Validate and canonicalize the field-statistics block of monitor.yml.
12926 @details Rejects every condition the field-statistics contract forbids, naming the
12927 offending window so a message points at one entry rather than the block.
12928 Returns a canonical form the flag resolver serializes without further
12929 interpretation, so validation and emission cannot disagree.
12931 @param[in] monitor_cfg Parsed monitor configuration.
12932 @param[in] case_cfg Parsed case configuration, used to check that each field's
12933 subsystem is active. Subsystem checks are skipped when None.
12934 @return Value returned by `normalize_field_statistics_config()`.
12936 raw = (monitor_cfg
or {}).get(
"field_statistics")
12938 return {
"enabled":
False,
"windows": []}
12939 if not isinstance(raw, dict):
12940 raise ValueError(
"'field_statistics' must be a mapping.")
12942 enabled = raw.get(
"enabled",
False)
12943 if not isinstance(enabled, bool):
12944 raise ValueError(
"'field_statistics.enabled' must be true or false.")
12945 windows_raw = raw.get(
"windows", [])
or []
12946 if not isinstance(windows_raw, list):
12947 raise ValueError(
"'field_statistics.windows' must be a list.")
12948 if enabled
and not windows_raw:
12949 raise ValueError(
"'field_statistics.enabled' is true but no window is defined.")
12953 for index, window
in enumerate(windows_raw):
12954 if not isinstance(window, dict):
12955 raise ValueError(f
"'field_statistics.windows[{index}]' must be a mapping.")
12956 name = window.get(
"name")
12957 if not isinstance(name, str)
or not name.strip():
12958 raise ValueError(f
"'field_statistics.windows[{index}]' needs a non-empty 'name'.")
12959 name = name.strip()
12962 if name
in seen_names:
12963 raise ValueError(f
"field statistics window '{name}' is defined more than once.")
12964 seen_names.add(name)
12966 start_time = window.get(
"start_time")
12967 if not isinstance(start_time, (int, float))
or isinstance(start_time, bool):
12968 raise ValueError(f
"field statistics window '{name}': 'start_time' must be a number.")
12969 end_time = window.get(
"end_time")
12970 if end_time
is not None:
12971 if not isinstance(end_time, (int, float))
or isinstance(end_time, bool):
12972 raise ValueError(f
"field statistics window '{name}': 'end_time' must be a number.")
12973 if float(end_time) <= float(start_time):
12975 f
"field statistics window '{name}': 'end_time' ({end_time}) must be greater "
12976 f
"than 'start_time' ({start_time})."
12979 weighting = window.get(
"weighting")
12980 if weighting
not in STATISTICS_WEIGHTING_MODES:
12982 f
"field statistics window '{name}': 'weighting' must be one of "
12983 f
"{list(STATISTICS_WEIGHTING_MODES)} (got {weighting!r})."
12986 has_step =
"step_cadence" in window
and window[
"step_cadence"]
is not None
12987 has_time =
"time_cadence" in window
and window[
"time_cadence"]
is not None
12988 if has_step == has_time:
12990 f
"field statistics window '{name}': set exactly one of 'step_cadence' and "
12993 step_cadence =
None
12994 time_cadence =
None
12996 step_cadence = window[
"step_cadence"]
12997 if not isinstance(step_cadence, int)
or isinstance(step_cadence, bool)
or step_cadence <= 0:
12999 f
"field statistics window '{name}': 'step_cadence' must be a positive integer "
13000 f
"(got {step_cadence!r})."
13003 time_cadence = window[
"time_cadence"]
13004 if (
not isinstance(time_cadence, (int, float))
or isinstance(time_cadence, bool)
13005 or float(time_cadence) <= 0.0):
13007 f
"field statistics window '{name}': 'time_cadence' must be a positive number "
13008 f
"(got {time_cadence!r})."
13012 f
"field statistics window '{name}'", window.get(
"fields"), window.get(
"covariances"),
13013 STATISTICS_ELIGIBLE_FIELDS, case_cfg)
13017 "start_time": float(start_time),
13018 "end_time":
None if end_time
is None else float(end_time),
13019 "weighting": weighting,
13020 "step_cadence": step_cadence,
13021 "time_cadence":
None if time_cadence
is None else float(time_cadence),
13023 "covariances": covariances,
13026 return {
"enabled": bool(enabled),
"windows": windows}
13031 @brief Validate and canonicalize the fields and covariances one window or ensemble requests.
13032 @param[in] label Owner named in error messages, such as `field statistics window 'w'`.
13033 @param[in] fields_raw The requested fields list of the owner.
13034 @param[in] covariances_raw The requested covariance pairs of the owner, or None.
13035 @param[in] eligible Fields the owner may name, with component counts and requirements.
13036 @param[in] case_cfg Parsed case configuration for subsystem checks, or None to skip them.
13037 @return Tuple of the canonical field entries and covariance pairs.
13039 if not isinstance(fields_raw, list)
or not fields_raw:
13040 raise ValueError(f
"{label}: 'fields' must be a non-empty list.")
13042 for field_entry
in fields_raw:
13043 if not isinstance(field_entry, dict):
13044 raise ValueError(f
"{label}: each 'fields' entry must be a mapping.")
13045 field_name = field_entry.get(
"field")
13046 if not isinstance(field_name, str)
or field_name
not in eligible:
13048 f
"{label}: field {field_name!r} cannot be accumulated. "
13049 f
"Available fields: {sorted(eligible)}."
13051 requirement = eligible[field_name][
"requires"]
13054 f
"{label}: field '{field_name}' requires the "
13055 f
"'{requirement}' subsystem, which is not enabled for this case."
13057 if any(existing[
"field"] == field_name
for existing
in fields):
13059 f
"{label}: field '{field_name}' is listed more than once."
13061 moments = field_entry.get(
"moments")
13062 if not isinstance(moments, list)
or not moments:
13064 f
"{label}: field '{field_name}' needs a non-empty "
13067 unknown = [m
for m
in moments
if m
not in STATISTICS_MOMENT_NAMES]
13070 f
"{label}: field '{field_name}' requests unknown "
13071 f
"moments {unknown}. Available moments: {list(STATISTICS_MOMENT_NAMES)}."
13075 fields.append({
"field": field_name,
"moments": [
"first"] + ([
"second"]
if "second" in moments
else [])})
13077 covariances_raw = covariances_raw
or []
13078 if not isinstance(covariances_raw, list):
13079 raise ValueError(f
"{label}: 'covariances' must be a list.")
13080 requested = {entry[
"field"]
for entry
in fields}
13082 for pair
in covariances_raw:
13083 if not isinstance(pair, list)
or len(pair) != 2:
13085 f
"{label}: each covariance must be a pair of field names."
13087 first, second = pair
13088 for member
in (first, second):
13089 if not isinstance(member, str)
or member
not in eligible:
13091 f
"{label}: covariance member {member!r} cannot be "
13092 f
"accumulated. Available fields: {sorted(eligible)}."
13094 if first == second:
13096 f
"{label}: covariance ['{first}', '{second}'] pairs a "
13097 "field with itself; request that through moments: [second] instead."
13099 missing = sorted({first, second} - requested)
13102 f
"{label}: covariance ['{first}', '{second}'] needs "
13103 f
"{missing} in 'fields' as well, because a co-moment is centered against their means."
13107 if (eligible[first][
"components"] == 3
13108 and eligible[second][
"components"] == 3):
13110 f
"{label}: covariance ['{first}', '{second}'] pairs two "
13111 "vector fields, which is not supported."
13113 if sorted((first, second))
in [sorted(existing)
for existing
in covariances]:
13115 f
"{label}: covariance ['{first}', '{second}'] is "
13116 "requested more than once."
13118 covariances.append([first, second])
13120 return fields, covariances
13125 @brief Serialize field-statistics configuration into control-file option lines.
13126 @details A window list is variable arity, so its option names are constructed from
13127 the index. Each name belongs to a family declared in the ingress audit
13129 @param[in] monitor_cfg Parsed monitor configuration.
13130 @param[in] case_cfg Parsed case configuration, for subsystem availability checks.
13131 @return Value returned by `resolve_field_statistics_flags()`.
13134 if not config[
"enabled"]:
13140 window_path = (
"monitor",
"field_statistics",
"windows",
"[]")
13142 def solver_time(key: str, value: float) -> float:
13144 @brief Convert one physical window time to solver time.
13145 @param[in] key Window key, for the recorded dimension.
13146 @param[in] value Physical time.
13147 @return Solver time.
13151 lines = [
"-field_statistics_enabled true",
13152 f
"-field_statistics_window_count {len(config['windows'])}"]
13153 for index, window
in enumerate(config[
"windows"]):
13154 prefix = f
"-field_statistics_window_{index}"
13155 lines.append(f
"{prefix}_name {window['name']}")
13156 lines.append(f
"{prefix}_start_time {solver_time('start_time', window['start_time'])!r}")
13159 if window[
"end_time"]
is not None:
13160 lines.append(f
"{prefix}_end_time {solver_time('end_time', window['end_time'])!r}")
13161 lines.append(f
"{prefix}_weighting {window['weighting']}")
13162 if window[
"step_cadence"]
is not None:
13163 lines.append(f
"{prefix}_step_cadence {window['step_cadence']}")
13165 lines.append(f
"{prefix}_time_cadence {solver_time('time_cadence', window['time_cadence'])!r}")
13166 lines.append(f
"{prefix}_field_count {len(window['fields'])}")
13167 for field_index, field_entry
in enumerate(window[
"fields"]):
13168 lines.append(f
"{prefix}_field_{field_index}_name {field_entry['field']}")
13169 lines.append(f
"{prefix}_field_{field_index}_moments {','.join(field_entry['moments'])}")
13170 lines.append(f
"{prefix}_covariance_count {len(window['covariances'])}")
13171 for pair_index, pair
in enumerate(window[
"covariances"]):
13172 lines.append(f
"{prefix}_covariance_{pair_index} {pair[0]},{pair[1]}")
13178 @brief Resolve the statistics console cadence, mirroring the particle one.
13179 @param[in] io_cfg Parsed monitor `io` block.
13180 @return Value returned by `resolve_statistics_console_output_frequency()`.
13182 if 'statistics_console_output_frequency' in io_cfg:
13183 return io_cfg[
'statistics_console_output_frequency']
13184 return io_cfg.get(
'data_output_frequency')
13189 @brief Validate and canonicalize physical-solution convergence monitoring.
13190 @param[in] monitor_cfg Parsed monitor.yml mapping.
13191 @return Canonical solution-monitoring configuration.
13192 @throws ValueError when convergence settings are invalid.
13194 if not isinstance(monitor_cfg, dict):
13195 raise ValueError(
"monitor.yml must be a mapping before solution monitoring can be normalized.")
13196 monitoring = monitor_cfg.get(
"solution_monitoring", {})
or {}
13197 if not isinstance(monitoring, dict):
13198 raise ValueError(
"solution_monitoring must be a mapping when provided.")
13199 convergence = monitoring.get(
"convergence", {})
or {}
13200 if not isinstance(convergence, dict):
13201 raise ValueError(
"solution_monitoring.convergence must be a mapping when provided.")
13202 enabled = convergence.get(
"enabled",
True)
13203 if not isinstance(enabled, bool):
13204 raise ValueError(
"solution_monitoring.convergence.enabled must be boolean.")
13206 normalized = {
"enabled": enabled,
"mode": mode.lower()}
13207 periodic_cfg = convergence.get(
"periodic_deterministic")
13208 statistical_cfg = convergence.get(
"statistical_steady")
13209 if mode ==
"PERIODIC_DETERMINISTIC":
13210 if not isinstance(periodic_cfg, dict):
13212 "solution_monitoring.convergence.periodic_deterministic is required for periodic_deterministic mode."
13214 period_steps = periodic_cfg.get(
"period_steps")
13215 if isinstance(period_steps, bool)
or not isinstance(period_steps, int)
or period_steps <= 0:
13217 "solution_monitoring.convergence.periodic_deterministic.period_steps must be a positive integer."
13219 normalized[
"periodic_deterministic"] = {
"period_steps": period_steps}
13220 elif periodic_cfg
is not None:
13222 "solution_monitoring.convergence.periodic_deterministic is only valid for periodic_deterministic mode."
13224 if mode ==
"STATISTICAL_STEADY":
13225 if not isinstance(statistical_cfg, dict):
13227 "solution_monitoring.convergence.statistical_steady is required for statistical_steady mode."
13229 window_steps = statistical_cfg.get(
"window_steps")
13230 if isinstance(window_steps, bool)
or not isinstance(window_steps, int)
or window_steps <= 0:
13232 "solution_monitoring.convergence.statistical_steady.window_steps must be a positive integer."
13234 normalized[
"statistical_steady"] = {
"window_steps": window_steps}
13235 elif statistical_cfg
is not None:
13237 "solution_monitoring.convergence.statistical_steady is only valid for statistical_steady mode."
13239 return {
"convergence": normalized}
13244 @brief Translate solution-monitoring YAML into the existing C convergence flags.
13245 @param[in] monitor_cfg Parsed monitor.yml mapping.
13246 @return Mapping of convergence options to explicit runtime values.
13250 "-solution_convergence_enabled":
"true" if convergence[
"enabled"]
else "false",
13251 "-solution_convergence_mode": f
'"{normalize_solution_convergence_mode(convergence["mode"])}"',
13253 if "periodic_deterministic" in convergence:
13254 flags[
"-solution_convergence_period_steps"] = convergence[
"periodic_deterministic"][
"period_steps"]
13255 if "statistical_steady" in convergence:
13256 flags[
"-solution_convergence_window_steps"] = convergence[
"statistical_steady"][
"window_steps"]
13261 config_dir: str =
None) -> dict:
13263 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
13264 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
13265 @param[in] run_id Argument passed to `prepare_monitor_files()`.
13266 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
13267 @param[in] source_files Argument passed to `prepare_monitor_files()`.
13268 @param[in] config_dir Optional configuration revision directory.
13269 @return Value returned by `prepare_monitor_files()`.
13271 print(
"[INFO] Generating monitoring files...")
13273 whitelist_path =
None
13276 monitor_cfg[
"logging"][
"enabled_functions"],
"monitor.logging.enabled_functions")
13278 run_dir, run_id, {
"logging": {
"enabled_functions": enabled}},
"logging",
"enabled_functions",
13279 "whitelist.run", source_files, config_dir=config_dir,
13282 print(
"[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
13286 profile_path =
None
13287 if profiling_cfg[
"mode"] ==
"selected":
13291 {
"profiling": {
"selected_functions": profiling_cfg[
"functions"]}},
13293 "selected_functions",
13296 config_dir=config_dir,
13299 print(f
"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
13302 "whitelist": whitelist_path,
13303 "profile": profile_path,
13304 "profiling": profiling_cfg,
13308 config_dir: str =
None) -> list:
13310 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
13311 and returns a list of their absolute paths.
13312 @details Handles both simple list format (for single-block cases) and a
13313 list-of-lists (for multi-block cases) for boundary conditions.
13314 @param[in] run_dir The path to the main run directory.
13315 @param[in] run_id The unique identifier for the run.
13316 @param[in] case_cfg The parsed case.yml configuration dictionary.
13317 @param[in] source_files A dictionary of source files for the header.
13318 @param[in] config_dir Optional configuration revision directory.
13319 @return A list of absolute paths to the generated BC files.
13320 @throws ValueError if the number of BC definitions does not match the number of blocks.
13322 print(
"[INFO] Generating boundary condition files...")
13323 config_dir = config_dir
or os.path.join(run_dir,
"config")
13324 os.makedirs(config_dir, exist_ok=
True)
13325 profile_dir = os.path.join(run_dir,
"inputs",
"inlet_profiles")
13326 os.makedirs(profile_dir, exist_ok=
True)
13327 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
13329 case_path = source_files.get(
"Case")
if source_files
else None
13330 profile_grid_dims =
None
13334 if any(bc.get(
"handler") ==
"prescribed_flow" for block
in prepared_blocks
for bc
in block):
13337 generated_files = []
13338 generated_profile_summaries = []
13339 generated_target_grid =
None
13340 field_slice_target_grid =
None
13341 for i, block_bcs_list
in enumerate(prepared_blocks):
13342 file_name =
"bcs.run" if num_blocks == 1
else f
"bcs_block{i}.run"
13343 bcs_file_path = os.path.join(config_dir, file_name)
13346 for bc
in block_bcs_list:
13347 face, bc_type, handler = bc[
'face'], bc[
'type'], bc[
'handler']
13348 params = dict(bc.get(
'params')
or {})
13349 if handler ==
"prescribed_flow":
13350 source = params.pop(
"source")
13352 staged_name = f
"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
13353 staged_path = os.path.join(profile_dir, staged_name)
13354 if os.path.isfile(staged_path):
13355 with open(staged_path,
"r", encoding=
"utf-8")
as stream:
13356 header = [line.strip()
for line
in stream
if line.strip()
and not line.lstrip().startswith(
"#")]
13357 if len(header) < 3
or header[0] !=
"PICSLICE":
13358 raise ValueError(f
"Locked inlet profile is not a PICSLICE file: {staged_path}")
13360 actual_dims = tuple(int(token)
for token
in header[2].split())
13361 except ValueError
as exc:
13362 raise ValueError(f
"Locked inlet profile has invalid dimensions: {staged_path}")
from exc
13363 if actual_dims != tuple(expected_dims):
13365 f
"Locked inlet profile {staged_path} has dimensions {actual_dims}; "
13366 f
"expected {tuple(expected_dims)}."
13369 f
"[INFO] Reusing locked prescribed_flow profile for block {i}, "
13370 f
"face {face}: {os.path.relpath(staged_path)}"
13372 params[
"source_file"] = os.path.abspath(staged_path)
13374 elif source[
"type"] ==
"file":
13376 source[
"path"], os.path.dirname(os.path.abspath(case_path))
13378 elif source[
"type"] ==
"generated":
13379 source_path = os.path.join(
13381 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.dimensional.picslice",
13383 if os.path.abspath(source_path) == os.path.abspath(staged_path):
13385 f
"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
13387 if source[
"generator"] ==
"square_duct_poiseuille":
13388 if generated_target_grid
is None:
13394 target_grid=generated_target_grid,
13397 script=source.get(
"script"),
13398 case_path=case_path,
13401 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
13402 summary.update({
"block": i,
"face": face})
13403 generated_profile_summaries.append(summary)
13404 elif source[
"type"] ==
"field_slice":
13405 source_path = os.path.join(
13407 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.dimensional.picslice",
13409 if os.path.abspath(source_path) == os.path.abspath(staged_path):
13411 f
"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
13413 if field_slice_target_grid
is None:
13419 field_slice_target_grid,
13424 summary.update({
"block": i,
"face": face})
13425 generated_profile_summaries.append(summary)
13426 elif source
is not None:
13427 raise ValueError(f
"Unsupported prescribed_flow source type '{source.get('type')}'.")
13428 if source
is not None:
13431 f
"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
13432 f
"{os.path.relpath(staged_path)} dims={summary['dims']}"
13434 params[
"source_file"] = os.path.abspath(staged_path)
13438 for k, v
in params.items():
13439 if isinstance(v, bool):
13440 value_str =
"true" if v
else "false"
13443 parts.append(f
"{k}={value_str}")
13444 params_str =
" ".join(parts)
13445 bcs_lines.append(f
"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
13447 with open(bcs_file_path,
"w")
as f: f.write(
"\n".join(bcs_lines))
13449 print(f
"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
13450 generated_files.append(os.path.abspath(bcs_file_path))
13452 if generated_profile_summaries:
13454 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
13456 return generated_files
13460 @brief Converts Python types to C-style command-line flag values.
13461 @param[in] value The Python object to convert (bool, list, or other).
13462 @return A string representation suitable for a C command-line parser.
13464 if isinstance(value, bool):
13465 return "1" if value
else "0"
13466 if isinstance(value, list):
13467 return ",".join(map(str, value))
13472 @brief Return programmatic-grid settings translated to the C node-count contract.
13473 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
13474 @return Value returned by `translate_programmatic_grid_settings()`.
13476 translated = dict(grid_settings)
13477 for dim_key
in (
"im",
"jm",
"km"):
13478 if dim_key
in translated:
13479 raw_val = translated[dim_key]
13480 if not isinstance(raw_val, int)
or raw_val <= 0:
13482 f
"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
13483 f
"(got {raw_val!r})."
13485 translated[dim_key] = raw_val + 1
13489PROGRAMMATIC_BRIDGE_GRID_KEYS = (
13491 "xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
13492 "rxs",
"rys",
"rzs",
13498 @brief Validate the scalar programmatic grid settings the bridge PICGRID is built from.
13499 @details A Python generator - an initial condition or a generated inlet profile - runs
13500 as a separate process and reads the programmatic grid from that bridge, which
13501 supports one block with scalar settings.
13502 @param[in] raw_settings programmatic_settings dict from case.yml.
13503 @throws ValueError when required scalar settings are missing or invalid.
13505 if not isinstance(raw_settings, dict):
13507 "grid.programmatic_settings must be a mapping when a Python generator reads the grid."
13510 missing = [key
for key
in PROGRAMMATIC_BRIDGE_GRID_KEYS
if key
not in raw_settings]
13513 "grid.programmatic_settings must include "
13514 f
"{missing} when grid.mode is 'programmatic_c' and a Python generator reads the grid."
13517 for key
in (
"im",
"jm",
"km"):
13518 value = raw_settings[key]
13519 if isinstance(value, bool)
or not isinstance(value, int)
or value <= 0:
13521 f
"grid.programmatic_settings.{key} must be a positive scalar integer cell count "
13522 "when a Python generator reads the programmatic_c grid."
13525 for key
in (
"xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
"rxs",
"rys",
"rzs"):
13526 value = raw_settings[key]
13527 if isinstance(value, (list, tuple, dict, bool)):
13529 f
"grid.programmatic_settings.{key} must be a scalar numeric value "
13530 "when a Python generator reads the programmatic_c grid."
13533 numeric = float(value)
13534 except (TypeError, ValueError):
13536 f
"grid.programmatic_settings.{key} must be a scalar numeric value "
13537 "when a Python generator reads the programmatic_c grid."
13539 if not math.isfinite(numeric):
13541 f
"grid.programmatic_settings.{key} must be finite "
13542 "when a Python generator reads the programmatic_c grid."
13544 if key
in {
"rxs",
"rys",
"rzs"}
and numeric <= 0.0:
13546 f
"grid.programmatic_settings.{key} must be positive "
13547 "when a Python generator reads the programmatic_c grid."
13553 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
13554 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
13555 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
13556 @param[in] raw_settings programmatic_settings dict from case.yml.
13557 @param[in] dest_path Destination PICGRID file path.
13558 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
13559 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
13563 raise ValueError(
"length_ref must be non-zero for programmatic grid generation.")
13564 IM = int(raw_settings.get(
"im", 0)) + 1
13565 JM = int(raw_settings.get(
"jm", 0)) + 1
13566 KM = int(raw_settings.get(
"km", 0)) + 1
13567 if IM < 2
or JM < 2
or KM < 2:
13569 f
"programmatic_settings im/jm/km must each be >= 1 "
13570 f
"(got im={IM-1}, jm={JM-1}, km={KM-1})."
13572 x_min = float(raw_settings.get(
"xMins", 0.0))
13573 x_max = float(raw_settings.get(
"xMaxs", 1.0))
13574 y_min = float(raw_settings.get(
"yMins", 0.0))
13575 y_max = float(raw_settings.get(
"yMaxs", 1.0))
13576 z_min = float(raw_settings.get(
"zMins", 0.0))
13577 z_max = float(raw_settings.get(
"zMaxs", 1.0))
13578 rx = float(raw_settings.get(
"rxs", 1.0))
13579 ry = float(raw_settings.get(
"rys", 1.0))
13580 rz = float(raw_settings.get(
"rzs", 1.0))
13582 def _stretched(idx, N, length, r):
13584 @brief Mirror of ComputeStretchedCoord from src/grid.c.
13585 @param[in] idx Node index along the axis.
13586 @param[in] N Total node count along the axis.
13587 @param[in] length Physical length of the axis.
13588 @param[in] r Geometric stretching ratio.
13589 @return Coordinate offset from the axis minimum.
13591 frac = idx / (N - 1.0)
13592 if abs(r - 1.0) < 1.0e-9:
13593 return length * frac
13594 return length * (r ** frac - 1.0) / (r - 1.0)
13596 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
13597 os.makedirs(os.path.dirname(dest_path), exist_ok=
True)
13598 with open(dest_path,
"w")
as fout:
13599 fout.write(
"PICGRID\n1\n")
13600 fout.write(f
"{IM} {JM} {KM}\n")
13601 for k
in range(KM):
13602 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
13603 for j
in range(JM):
13604 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
13605 for i
in range(IM):
13606 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
13608 f
"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
13609 f
"{format_picgrid_coordinate(z)}\n"
13611 total_nodes = IM * JM * KM
13612 return {
"nblk": 1,
"dims": [(IM, JM, KM)],
"total_nodes": total_nodes}
13615GRID_DA_PROCESSOR_KEYS = (
"da_processors_x",
"da_processors_y",
"da_processors_z")
13620 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
13621 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
13622 @return Value returned by `resolve_grid_da_processor_layout()`.
13627 for key
in GRID_DA_PROCESSOR_KEYS:
13628 value = grid_cfg.get(key)
13629 if isinstance(value, (list, tuple)):
13631 f
"grid.{key} must be a scalar integer. "
13632 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
13634 if value
is not None:
13635 if not isinstance(value, int)
or value <= 0:
13636 raise ValueError(f
"grid.{key} must be a positive integer when provided (got {value}).")
13637 top_level[key] = value
13639 legacy_settings = grid_cfg.get(
"programmatic_settings")
13640 if isinstance(legacy_settings, dict):
13641 for key
in GRID_DA_PROCESSOR_KEYS:
13642 value = legacy_settings.get(key)
13643 if isinstance(value, (list, tuple)):
13645 f
"grid.programmatic_settings.{key} must be a scalar integer. "
13646 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
13648 if value
is not None:
13649 if not isinstance(value, int)
or value <= 0:
13651 f
"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
13653 legacy[key] = value
13656 for key
in GRID_DA_PROCESSOR_KEYS:
13657 top_value = top_level.get(key)
13658 legacy_value = legacy.get(key)
13659 if top_value
is not None and legacy_value
is not None and top_value != legacy_value:
13661 f
"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
13662 "define the processor layout in only one place."
13664 if top_value
is not None:
13665 resolved[key] = top_value
13666 elif legacy_value
is not None:
13667 resolved[key] = legacy_value
13673 staged_grid:
"str | None" =
None) ->
"list | None":
13675 @brief Per-block (IM, JM, KM) node counts the solver will read, without building the grid.
13676 @details A staged PICGRID, when one exists, is authoritative. Otherwise a file grid's
13677 header is read, a programmatic grid's cell counts are converted to nodes,
13678 and a `grid_gen` grid's cell counts are resolved by grid.gen's own
13679 parameter precedence without generating any coordinates.
13680 @param[in] case_cfg Parsed case configuration.
13681 @param[in] case_path Path to case.yml, for case-relative paths.
13682 @param[in] staged_grid Already staged `inputs/grid/grid.run`, if any.
13683 @return List of node-count tuples, or None when the dimensions cannot be determined.
13685 grid_cfg = case_cfg.get(
"grid")
or {}
13686 expected_nblk = int(((case_cfg.get(
"models")
or {}).get(
"domain")
or {}).get(
"blocks", 1))
13687 case_dir = os.path.dirname(os.path.abspath(case_path))
13688 mode = grid_cfg.get(
"mode")
13690 if staged_grid
and os.path.isfile(staged_grid):
13695 if mode ==
"programmatic_c":
13697 return [(settings[
"im"], settings[
"jm"], settings[
"km"])]
13698 if mode ==
"grid_gen":
13699 generator = grid_cfg.get(
"generator")
or {}
13700 if generator.get(
"script"):
13703 if generator.get(
"grid_type"):
13704 tokens.append(str(generator[
"grid_type"]))
13705 tokens.extend(str(token)
for token
in (generator.get(
"cli_args")
or []))
13707 with contextlib.redirect_stdout(StringIO()), contextlib.redirect_stderr(StringIO()):
13708 _, params = grid_gen.resolve_grid_parameters(tokens)
13709 return [(params.ncells_i + 1, params.ncells_j + 1, params.ncells_k + 1)]
13710 except (SystemExit, Exception):
13718 @brief Whether any face of any block is PERIODIC, which sets the DMDA stencil width.
13719 @param[in] case_cfg Parsed case configuration.
13720 @return True when grid setup will use stencil width 3 on every axis.
13726 return any(entry.get(
"type") ==
"PERIODIC" for block
in blocks
for entry
in block)
13730 block_dims:
"list | None" =
None, solver_cfg:
"dict | None" =
None,
13731 any_periodic: bool =
False, source:
"str | None" =
None) -> list:
13733 @brief Problems with a user-set DMDA layout that would abort grid setup.
13734 @details Mirrors `InitializeAllGridDMs()` in src/grid.c: each coarser level holds
13735 (IM + 1) / 2 nodes on an axis that is not semi-coarsened, the DMDA carries
13736 IM + 1 points, and PETSc's default split gives the smallest rank
13737 points // ranks of them. Grid setup requires that to reach the stencil
13738 width, which is 3 on every axis when any face is periodic and 2 otherwise.
13739 Only the coarsest level can fail, because every finer level is aligned to
13741 @param[in] grid_cfg The case's grid block.
13742 @param[in] num_procs MPI processes the solve will launch, or None when unknown (validate
13743 without a cluster profile), which skips only the product check.
13744 @param[in] block_dims Per-block node counts, or None to skip the multigrid check.
13745 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels and semi-coarsening.
13746 @param[in] any_periodic Whether any face is PERIODIC.
13747 @param[in] source Where the layout was given, as `--ni/--nj/--nk` or
13748 `cluster.yml execution.decomposition`; None for case.yml's own keys.
13749 @return List of messages; empty when the layout is usable or not set.
13752 if source ==
"--ni/--nj/--nk":
13753 names = dict(zip(GRID_DA_PROCESSOR_KEYS, (
"--ni",
"--nj",
"--nk")))
13754 together =
"--ni x --nj x --nk"
13756 names = {key: f
"{source}.{axis}" for key, axis
in zip(GRID_DA_PROCESSOR_KEYS,
"ijk")}
13757 together = f
"{source} (i, j, k)"
13759 names = {key: f
"grid.{key}" for key
in GRID_DA_PROCESSOR_KEYS}
13760 together =
"grid.da_processors_x/y/z"
13761 if not layout
or (num_procs
is not None and num_procs <= 1):
13764 if num_procs
is not None and all(layout.get(key)
is not None for key
in GRID_DA_PROCESSOR_KEYS):
13766 for key
in GRID_DA_PROCESSOR_KEYS:
13767 total_layout *= layout[key]
13768 if total_layout != num_procs:
13769 printable =
" x ".join(str(layout[key])
for key
in GRID_DA_PROCESSOR_KEYS)
13771 "DMDA processor layout mismatch: "
13772 f
"{together} is {printable} (product {total_layout}), "
13773 f
"but this run requests {num_procs} MPI processes. "
13774 "Give values whose product equals the requested MPI process count, or "
13775 "remove all three settings to let PICurv choose the layout."
13779 multigrid = (((solver_cfg
or {}).get(
"poisson_solver")
or {}).get(
"multigrid")
or {})
13780 levels = int(multigrid.get(
"levels", 3))
13781 semi = multigrid.get(
"semi_coarsening")
or {}
13782 stencil = 3
if any_periodic
else 2
13783 for block, dims
in enumerate(block_dims):
13784 for axis, key, nodes
in zip(
"ijk", GRID_DA_PROCESSOR_KEYS, dims):
13785 ranks = layout.get(key)
13789 if str(semi.get(axis,
False)).strip().lower()
not in (
"true",
"1",
"yes"):
13790 for _
in range(levels - 1):
13791 coarse = (coarse + 1) // 2
13792 points = coarse + 1
13793 if points // ranks < stencil:
13795 f
"{names[key]} = {ranks} is too many ranks for block {block}'s "
13796 f
"{axis} axis at {levels} multigrid levels: the coarsest level has "
13797 f
"{points} points there, so a rank gets {points // ranks}, below the "
13798 f
"stencil width {stencil}"
13799 + (
" (3 on every axis because a face is PERIODIC)" if any_periodic
else "")
13800 + f
". Grid setup would abort with 'Local width ... is smaller than "
13801 f
"stencil width'. Use at most {max(points // stencil, 1)} rank(s) on "
13802 "this axis, or lower poisson_solver.multigrid.levels."
13809 @brief Resolve `-n` and the optional `--ni/--nj/--nk` of `picurv run` into one rank count.
13810 @details The three counts go together. Given them, `-n` defaults to their product and
13811 must equal it when also given; without them, `-n` defaults to 1. Sets
13812 `args.num_procs` and `args.decomposition` (a tuple or None).
13813 @param[in,out] args Parsed `picurv run` arguments.
13815 parts = [getattr(args, key,
None)
for key
in (
"ni",
"nj",
"nk")]
13816 args.decomposition =
None
13817 if any(part
is not None for part
in parts):
13818 if any(part
is None or part <= 0
for part
in parts):
13819 fail_cli_usage(
"--ni, --nj and --nk are given together, as positive integers.")
13820 product = parts[0] * parts[1] * parts[2]
13821 if args.num_procs
is None:
13822 args.num_procs = product
13823 elif args.num_procs != product:
13824 fail_cli_usage(f
"--ni x --nj x --nk is {parts[0]} x {parts[1]} x {parts[2]} = {product} ranks, "
13825 f
"but -n is {args.num_procs}; give one or make them agree.")
13826 args.decomposition = tuple(parts)
13827 if args.num_procs
is None:
13829 if args.num_procs < 1:
13835 @brief Express an (ni, nj, nk) tuple as the da_processors mapping the layout checks read.
13836 @param[in] layout Rank counts along i, j, k.
13837 @return {"da_processors_x": ni, "da_processors_y": nj, "da_processors_z": nk}.
13839 return dict(zip(GRID_DA_PROCESSOR_KEYS, layout))
13843 any_periodic: bool):
13845 @brief Choose the MPI decomposition of a structured grid for a rank count.
13846 @details Every ordered (ni, nj, nk) with product `num_procs` that grid setup accepts at
13847 the multigrid levels in use (da_processor_layout_problems) is a candidate. The
13848 one cutting the least grid area between ranks wins, so a thin axis, such as the
13849 i axis of a 2D case, is not split while another can be; ties go to the most even
13850 split, then to more ranks on k, then on j.
13851 @param[in] num_procs MPI ranks.
13852 @param[in] block_dims Per-block node counts.
13853 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels and semi-coarsening.
13854 @param[in] any_periodic Whether any face is PERIODIC, which sets the stencil width.
13855 @return (ni, nj, nk), or None when no decomposition is feasible.
13858 for ni
in range(1, num_procs + 1):
13861 for nj
in range(1, num_procs // ni + 1):
13862 if (num_procs // ni) % nj:
13864 layout = (ni, nj, num_procs // ni // nj)
13866 solver_cfg, any_periodic):
13868 cut = imbalance = 0.0
13869 for dims
in block_dims:
13870 cells = [max(d - 1, 1)
for d
in dims]
13871 cut += sum((layout[a] - 1) * cells[(a + 1) % 3] * cells[(a + 2) % 3]
for a
in range(3))
13874 largest *= -(-cells[a] // layout[a])
13875 imbalance = max(imbalance, largest * num_procs / (cells[0] * cells[1] * cells[2]))
13876 key = (cut, imbalance, -layout[2], -layout[1])
13877 if best
is None or key < best[0]:
13878 best = (key, layout)
13879 return best[1]
if best
else None
13883 solver_cfg:
"dict | None", any_periodic: bool,
13884 explicit=
None, cluster_cfg:
"dict | None" =
None) -> dict:
13886 @brief Resolve the MPI decomposition a stage launches with.
13887 @details In order of precedence: `--ni/--nj/--nk`, cluster.yml
13888 `execution.decomposition`, the deprecated case.yml `grid.da_processors_*`, and
13889 otherwise choose_decomposition(). A set layout must multiply to the rank count
13890 and pass the multigrid check. When the grid's size cannot be known before the
13891 run, PETSc chooses.
13892 @param[in] num_procs MPI ranks of the stage.
13893 @param[in] grid_cfg The case's grid block.
13894 @param[in] block_dims Per-block node counts, or None when unknown.
13895 @param[in] solver_cfg Parsed solver.yml.
13896 @param[in] any_periodic Whether any face is PERIODIC.
13897 @param[in] explicit (ni, nj, nk) from the command line, or None.
13898 @param[in] cluster_cfg Parsed cluster.yml, or None.
13899 @return {"layout": (ni, nj, nk) or None, "source": str, "problems": [str]}.
13902 return {
"layout": (1, 1, 1),
"source":
"serial",
"problems": []}
13903 layout, source =
None,
None
13905 layout, source = tuple(explicit),
"--ni/--nj/--nk"
13906 elif isinstance(((cluster_cfg
or {}).get(
"execution")
or {}).get(
"decomposition"), dict):
13907 spec = cluster_cfg[
"execution"][
"decomposition"]
13908 if not all(isinstance(spec.get(axis), int)
and spec.get(axis) > 0
for axis
in "ijk"):
13909 return {
"layout":
None,
"source":
"cluster.yml",
13910 "problems": [
"cluster.yml execution.decomposition needs positive integers i, j and k."]}
13911 layout, source = (spec[
"i"], spec[
"j"], spec[
"k"]),
"cluster.yml execution.decomposition"
13915 print(
"[WARNING] case.yml grid.da_processors_x/y/z is deprecated; give the decomposition "
13916 "with --ni/--nj/--nk or cluster.yml execution.decomposition.", file=sys.stderr)
13917 if len(legacy) == 3:
13918 layout, source = tuple(legacy[key]
for key
in GRID_DA_PROCESSOR_KEYS),
"case.yml (deprecated)"
13921 return {
"layout":
None,
"partial": legacy,
"source":
"case.yml (deprecated, partial)",
13922 "problems": problems}
13923 if layout
is not None:
13924 return {
"layout": layout,
"source": source,
13927 None if source.startswith(
"case.yml")
else source)}
13929 return {
"layout":
None,
"source":
"PETSc (grid size unknown before the run)",
"problems": []}
13932 feasible = [n
for n
in range(max(num_procs - 16, 2), num_procs + 17)
13934 nearest = sorted(feasible, key=
lambda n: (abs(n - num_procs), n))[:3]
13935 return {
"layout":
None,
"source":
"auto",
"problems": [
13936 f
"No decomposition of {num_procs} ranks fits this grid at its multigrid levels "
13937 f
"(each rank needs at least the stencil width of points on the coarsest level). "
13938 + (f
"Rank counts that fit nearby: {', '.join(map(str, sorted(nearest)))}." if nearest
13939 else "Lower poisson_solver.multigrid.levels or refine the grid.")]}
13940 return {
"layout": layout,
"source":
"auto",
"problems": []}
13945 @brief The JSON-serializable record of a resolved decomposition for run metadata.
13946 @param[in] resolved Result of resolve_decomposition(), or None.
13947 @return {"layout": [ni, nj, nk] or None, "source": str}, or None.
13951 return {
"layout":
list(resolved[
"layout"])
if resolved.get(
"layout")
else None,
13952 "source": resolved[
"source"]}
13957 @brief One line stating the decomposition a stage launches with.
13958 @param[in] resolved Result of resolve_decomposition().
13959 @param[in] num_procs MPI ranks of the stage.
13960 @return Human-readable description.
13962 if resolved.get(
"problems"):
13963 return f
"MPI decomposition: none usable for {num_procs} rank(s) ({resolved['source']})."
13964 if resolved.get(
"layout")
is None:
13965 return f
"MPI decomposition: chosen by PETSc for {num_procs} rank(s) ({resolved['source']})."
13966 ni, nj, nk = resolved[
"layout"]
13967 return f
"MPI decomposition: {ni} x {nj} x {nk} (i x j x k) for {num_procs} rank(s), from {resolved['source']}."
13971 block_dims:
"list | None" =
None,
13972 solver_cfg:
"dict | None" =
None,
13973 any_periodic: bool =
False,
13974 explicit=
None, cluster_cfg:
"dict | None" =
None) -> dict:
13976 @brief Resolve the solver's MPI decomposition, report it, and append its flags.
13977 @param[in] control_lines Control-file lines to extend.
13978 @param[in] grid_cfg The case's grid block.
13979 @param[in] num_procs MPI ranks of the solve.
13980 @param[in] block_dims Per-block node counts, for the multigrid check and the choice.
13981 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels.
13982 @param[in] any_periodic Whether any face is PERIODIC.
13983 @param[in] explicit (ni, nj, nk) from the command line, or None.
13984 @param[in] cluster_cfg Parsed cluster.yml, or None.
13985 @return The resolved decomposition (resolve_decomposition()).
13986 @throws ValueError when the decomposition would abort grid setup or none fits.
13989 explicit, cluster_cfg)
13990 if resolved[
"problems"]:
13991 raise ValueError(
" ".join(resolved[
"problems"]))
13992 print(f
"[INFO] {describe_decomposition(resolved, num_procs)}")
13993 layout = resolved.get(
"layout")
13994 if num_procs > 1
and layout
is not None:
13995 control_lines.extend(f
"-{key} {value}" for key, value
in zip(GRID_DA_PROCESSOR_KEYS, layout))
13996 elif num_procs > 1
and resolved.get(
"partial"):
13997 control_lines.extend(f
"-{key} {value}" for key, value
in resolved[
"partial"].items())
14002 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
14003 @param[in] value Canonical momentum solver string from YAML.
14004 @return Canonical value accepted by -mom_solver_type.
14005 @throws ValueError if the input cannot be mapped.
14009 raise ValueError(
"momentum solver type cannot be None")
14011 raw = str(value).strip()
14013 "Explicit RK4":
"EXPLICIT_RK",
14014 "Dual Time Picard Jameson RK":
"DUALTIME_PICARD_JAMESON_RK",
14015 "Dual Time Picard RK4":
"DUALTIME_PICARD_JAMESON_RK",
14016 "Newton Krylov":
"newton_krylov",
14020 f
"Unknown momentum solver '{value}'. Use one of: "
14021 "'Explicit RK4', 'Dual Time Picard Jameson RK', 'Newton Krylov'."
14028 @brief Validate and normalize the structured Newton--Krylov solver block.
14029 @param[in] cfg Structured `momentum_solver.newton_krylov` mapping.
14030 @return Normalized copy containing only supported structured fields.
14032 root =
"momentum_solver.newton_krylov"
14033 if not isinstance(cfg, dict):
14034 raise ValueError(f
"{root} must be a mapping.")
14036 unknown = sorted(set(cfg) - {
14037 "jacobian",
"preconditioner",
"nonlinear_solver",
"linear_solver",
14040 raise ValueError(f
"{root} has unsupported key(s): {unknown}.")
14044 def _mapping(parent: dict, key: str, path: str) -> dict:
14046 @brief Read and validate one optional nested Newton mapping.
14047 @param[in] parent Parent mapping.
14048 @param[in] key Nested key to read.
14049 @param[in] path User-facing YAML path for errors.
14050 @return Nested mapping, or an empty mapping when omitted.
14052 value = parent.get(key, {})
14053 if value
is None or not isinstance(value, dict):
14054 raise ValueError(f
"{path} must be a mapping when provided.")
14057 def _method(value, path: str) -> str:
14059 @brief Normalize one nonempty PETSc solver/type token.
14060 @param[in] value YAML token value.
14061 @param[in] path User-facing YAML path for errors.
14062 @return Lowercase PETSc token.
14064 if not isinstance(value, str)
or not value.strip():
14065 raise ValueError(f
"{path} must be a non-empty string.")
14066 return value.strip().lower()
14068 def _tolerance(value, path: str):
14070 @brief Validate one finite nonnegative tolerance.
14071 @param[in] value YAML tolerance value.
14072 @param[in] path User-facing YAML path for errors.
14073 @return Original validated value.
14075 if isinstance(value, bool):
14076 raise ValueError(f
"{path} must be numeric and nonnegative.")
14078 numeric = float(value)
14079 except (TypeError, ValueError)
as exc:
14080 raise ValueError(f
"{path} must be numeric and nonnegative.")
from exc
14081 if not math.isfinite(numeric)
or numeric < 0.0:
14082 raise ValueError(f
"{path} must be numeric and nonnegative.")
14085 def _positive_integer(value, path: str):
14087 @brief Validate one positive integer count.
14088 @param[in] value YAML count value.
14089 @param[in] path User-facing YAML path for errors.
14090 @return Original validated integer.
14092 if isinstance(value, bool)
or not isinstance(value, int)
or value <= 0:
14093 raise ValueError(f
"{path} must be a positive integer.")
14096 jacobian_path = f
"{root}.jacobian"
14097 if "jacobian" not in cfg:
14098 normalized[
"jacobian"] = {
14099 "type":
"finite_difference",
14100 "finite_difference": {
"mode":
"matrix_free"},
14103 jacobian = _mapping(cfg,
"jacobian", jacobian_path)
14104 unknown = sorted(set(jacobian) - {
"type",
"finite_difference"})
14106 raise ValueError(f
"{jacobian_path} has unsupported key(s): {unknown}.")
14107 if "type" not in jacobian:
14108 raise ValueError(f
"{jacobian_path}.type is required when {jacobian_path} is provided.")
14109 jacobian_type = _method(jacobian[
"type"], f
"{jacobian_path}.type")
14110 if jacobian_type ==
"frozen_momentum_approximation":
14112 f
"{jacobian_path}.type 'frozen_momentum_approximation' is not implemented."
14114 if jacobian_type !=
"finite_difference":
14116 f
"{jacobian_path}.type currently supports only 'finite_difference' "
14117 f
"(got '{jacobian_type}')."
14119 finite_difference_path = f
"{jacobian_path}.finite_difference"
14120 if "finite_difference" not in jacobian:
14122 f
"{finite_difference_path} is required when {jacobian_path}.type is "
14123 "'finite_difference'."
14125 finite_difference = _mapping(
14126 jacobian,
"finite_difference", finite_difference_path
14128 unknown = sorted(set(finite_difference) - {
"mode"})
14130 raise ValueError(f
"{finite_difference_path} has unsupported key(s): {unknown}.")
14131 if "mode" not in finite_difference:
14132 raise ValueError(f
"{finite_difference_path}.mode is required.")
14133 finite_difference_mode = _method(
14134 finite_difference[
"mode"], f
"{finite_difference_path}.mode"
14136 if finite_difference_mode ==
"colored_sparse":
14138 f
"{finite_difference_path}.mode 'colored_sparse' is not implemented."
14140 if finite_difference_mode !=
"matrix_free":
14142 f
"{finite_difference_path}.mode currently supports only 'matrix_free' "
14143 f
"(got '{finite_difference_mode}')."
14145 normalized[
"jacobian"] = {
14146 "type": jacobian_type,
14147 "finite_difference": {
"mode": finite_difference_mode},
14150 preconditioner = _mapping(
14151 cfg,
"preconditioner", f
"{root}.preconditioner"
14152 )
if "preconditioner" in cfg
else {}
14153 preconditioner_path = f
"{root}.preconditioner"
14154 unknown = sorted(set(preconditioner) - {
"model",
"structure"})
14156 raise ValueError(f
"{preconditioner_path} has unsupported key(s): {unknown}.")
14157 if "preconditioner" in cfg
and "model" not in preconditioner:
14159 f
"{preconditioner_path}.model is required when {preconditioner_path} is provided."
14161 model = _method(preconditioner.get(
"model",
"none"), f
"{preconditioner_path}.model")
14162 if model
not in NEWTON_KRYLOV_PRECONDITIONER_MODELS:
14164 f
"{preconditioner_path}.model supports only 'none' or 'frozen_momentum_jacobian'."
14166 structure = _mapping(
14167 preconditioner,
"structure", f
"{preconditioner_path}.structure"
14168 )
if "structure" in preconditioner
else {}
14169 unknown = sorted(set(structure) - {
"type"})
14171 raise ValueError(f
"{preconditioner_path}.structure has unsupported key(s): {unknown}.")
14172 structure_type = _method(
14173 structure.get(
"type",
"none"), f
"{preconditioner_path}.structure.type"
14175 if structure_type
not in NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES:
14177 f
"{preconditioner_path}.structure.type supports only 'none' or 'point_block'."
14179 if model ==
"none" and structure:
14180 raise ValueError(f
"{preconditioner_path}.model 'none' does not accept a matrix structure.")
14181 if model ==
"frozen_momentum_jacobian" and structure_type !=
"point_block":
14183 f
"{preconditioner_path}.model 'frozen_momentum_jacobian' requires "
14184 f
"{preconditioner_path}.structure.type 'point_block'."
14186 normalized[
"preconditioner"] = {
14188 "structure": {
"type": structure_type},
14191 nonlinear = _mapping(cfg,
"nonlinear_solver", f
"{root}.nonlinear_solver")
14192 nonlinear_path = f
"{root}.nonlinear_solver"
14193 unknown = sorted(set(nonlinear) - {
14194 "method",
"absolute_tolerance",
"relative_tolerance",
"step_tolerance",
14195 "max_iterations",
"line_search",
"eisenstat_walker",
14198 raise ValueError(f
"{nonlinear_path} has unsupported key(s): {unknown}.")
14200 if "method" in nonlinear:
14201 nonlinear_out[
"method"] = _method(nonlinear[
"method"], f
"{nonlinear_path}.method")
14202 for key
in (
"absolute_tolerance",
"relative_tolerance",
"step_tolerance"):
14203 if key
in nonlinear:
14204 nonlinear_out[key] = _tolerance(nonlinear[key], f
"{nonlinear_path}.{key}")
14205 if "max_iterations" in nonlinear:
14206 nonlinear_out[
"max_iterations"] = _positive_integer(
14207 nonlinear[
"max_iterations"], f
"{nonlinear_path}.max_iterations"
14209 if "line_search" in nonlinear:
14210 line_search = _mapping(nonlinear,
"line_search", f
"{nonlinear_path}.line_search")
14211 unknown = sorted(set(line_search) - {
"type"})
14213 raise ValueError(f
"{nonlinear_path}.line_search has unsupported key(s): {unknown}.")
14214 nonlinear_out[
"line_search"] = {}
14215 if "type" in line_search:
14216 nonlinear_out[
"line_search"][
"type"] = _method(
14217 line_search[
"type"], f
"{nonlinear_path}.line_search.type"
14219 if "eisenstat_walker" in nonlinear:
14220 ew_path = f
"{nonlinear_path}.eisenstat_walker"
14221 ew = _mapping(nonlinear,
"eisenstat_walker", ew_path)
14223 "enabled",
"version",
"initial_relative_tolerance",
14224 "maximum_relative_tolerance",
"gamma",
"exponent",
14225 "safeguard_exponent",
"safeguard_threshold",
14227 unknown = sorted(set(ew) - ew_keys)
14229 raise ValueError(f
"{ew_path} has unsupported key(s): {unknown}.")
14230 enabled = ew.get(
"enabled",
True)
14231 if not isinstance(enabled, bool):
14232 raise ValueError(f
"{ew_path}.enabled must be boolean.")
14233 if not enabled
and set(ew) - {
"enabled"}:
14234 raise ValueError(f
"{ew_path} parameters require enabled: true.")
14235 ew_out = {
"enabled": enabled}
14236 if "version" in ew:
14237 version = ew[
"version"]
14238 if isinstance(version, bool)
or not isinstance(version, int)
or version
not in {1, 2, 3, 4}:
14239 raise ValueError(f
"{ew_path}.version must be one of 1, 2, 3, or 4.")
14240 ew_out[
"version"] = version
14242 "initial_relative_tolerance": (0.0, 1.0,
False,
True),
14243 "maximum_relative_tolerance": (0.0, 1.0,
False,
True),
14244 "gamma": (0.0, 1.0,
False,
False),
14245 "exponent": (1.0, 2.0,
True,
False),
14246 "safeguard_threshold": (0.0, 1.0,
True,
True),
14248 for key, (lower, upper, lower_open, upper_open)
in bounds.items():
14251 value = _tolerance(ew[key], f
"{ew_path}.{key}")
14252 numeric = float(value)
14253 valid_lower = numeric > lower
if lower_open
else numeric >= lower
14254 valid_upper = numeric < upper
if upper_open
else numeric <= upper
14255 if not (valid_lower
and valid_upper):
14256 brackets = (
"(" if lower_open
else "[") + f
"{lower}, {upper}" + (
")" if upper_open
else "]")
14257 raise ValueError(f
"{ew_path}.{key} must be in {brackets}.")
14258 ew_out[key] = value
14259 if "safeguard_exponent" in ew:
14260 ew_out[
"safeguard_exponent"] = _tolerance(
14261 ew[
"safeguard_exponent"], f
"{ew_path}.safeguard_exponent"
14263 nonlinear_out[
"eisenstat_walker"] = ew_out
14264 normalized[
"nonlinear_solver"] = nonlinear_out
14266 linear = _mapping(cfg,
"linear_solver", f
"{root}.linear_solver")
14267 linear_path = f
"{root}.linear_solver"
14268 unknown = sorted(set(linear) - {
14269 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
14270 "gmres",
"preconditioner",
14273 raise ValueError(f
"{linear_path} has unsupported key(s): {unknown}.")
14276 if "method" in linear:
14277 method = _method(linear[
"method"], f
"{linear_path}.method")
14278 linear_out[
"method"] = method
14279 for key
in (
"absolute_tolerance",
"relative_tolerance"):
14281 linear_out[key] = _tolerance(linear[key], f
"{linear_path}.{key}")
14282 if "max_iterations" in linear:
14283 linear_out[
"max_iterations"] = _positive_integer(
14284 linear[
"max_iterations"], f
"{linear_path}.max_iterations"
14286 if "gmres" in linear:
14287 gmres = _mapping(linear,
"gmres", f
"{linear_path}.gmres")
14288 unknown = sorted(set(gmres) - {
"restart"})
14290 raise ValueError(f
"{linear_path}.gmres has unsupported key(s): {unknown}.")
14291 linear_out[
"gmres"] = {}
14292 if "restart" in gmres:
14293 if method
not in GMRES_RESTART_METHODS:
14295 f
"{linear_path}.gmres.restart is valid only when {linear_path}.method "
14296 "is one of 'gmres', 'fgmres', or 'lgmres'."
14298 linear_out[
"gmres"][
"restart"] = _positive_integer(
14299 gmres[
"restart"], f
"{linear_path}.gmres.restart"
14301 if "preconditioner" in linear:
14302 compatibility_pc = _mapping(linear,
"preconditioner", f
"{linear_path}.preconditioner")
14303 unknown = sorted(set(compatibility_pc) - {
"type"})
14305 raise ValueError(f
"{linear_path}.preconditioner has unsupported key(s): {unknown}.")
14306 if "type" in compatibility_pc:
14307 pc_type = _method(compatibility_pc[
"type"], f
"{linear_path}.preconditioner.type")
14308 if pc_type !=
"none":
14310 f
"{linear_path}.preconditioner.type is a deprecated compatibility alias "
14311 "and supports only 'none'."
14313 if "preconditioner" in cfg
and model !=
"none":
14315 f
"{linear_path}.preconditioner.type 'none' conflicts with "
14316 f
"{preconditioner_path}.model '{model}'."
14319 f
"{linear_path}.preconditioner.type is deprecated; use "
14320 f
"{preconditioner_path}.model: none.",
14324 normalized[
"linear_solver"] = linear_out
14329 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
14330 @param[in] value Human-readable solution-convergence mode selector.
14331 @return Canonical string accepted by `-solution_convergence_mode`.
14332 @throws ValueError if the input cannot be mapped.
14335 raise ValueError(
"solution_convergence.mode cannot be None")
14337 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
14339 "steady_deterministic":
"STEADY_DETERMINISTIC",
14340 "periodic_deterministic":
"PERIODIC_DETERMINISTIC",
14341 "statistical_steady":
"STATISTICAL_STEADY",
14342 "transient":
"TRANSIENT",
14344 mapped = aliases.get(normalized)
14347 f
"Unknown solution_convergence.mode '{value}'. Use one of: "
14348 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
14354 @brief Maps canonical field init mode names to C enum/int codes (-finit).
14355 @param[in] value Canonical field initialization mode.
14356 @return Canonical integer code accepted by -finit.
14357 @throws ValueError if the input cannot be mapped.
14361 raise ValueError(
"field initialization mode cannot be None")
14367 }.get(str(value).strip())
14370 f
"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
14376 @brief Normalize a file IC field selector to its staged basename and C enum value.
14377 @param[in] value User-facing Ucat or Ucont selector.
14378 @return Tuple of staged field basename and C enum value.
14380 normalized = str(value
or "").strip().lower()
14381 if normalized ==
"ucat":
14383 if normalized ==
"ucont":
14385 raise ValueError(
"initial_conditions.field must be 'Ucat' or 'Ucont'.")
14389IC_PAYLOAD_DIMENSIONS = {0: VELOCITY, 1: VOLUME_FLUX}
14394 @brief Resolve which scales a file-backed initial condition was written in.
14395 @details A payload is physical unless it declares otherwise. A field saved by an
14396 earlier run is in that run's solver units, so it names the case that wrote
14397 it (`source_case`) or gives its scales directly, the same way a
14398 `field_slice` inlet profile does.
14399 @param[in] ic Initial-condition YAML mapping.
14400 @param[in] field_code C field code: 0 for Ucat, 1 for Ucont.
14401 @return Mapping with `source_case`, or `velocity_scale` and `length_scale`, or empty.
14402 @throws ValueError on a conflicting, incomplete, or invalid declaration.
14404 source_case = ic.get(
"source_case")
14405 has_scale = any(key
in ic
for key
in (
"velocity_scale",
"length_scale"))
14406 if source_case
is not None:
14408 raise ValueError(
"initial_conditions: give source_case or velocity_scale/length_scale, not both.")
14409 if not isinstance(source_case, str)
or not source_case.strip():
14410 raise ValueError(
"initial_conditions.source_case must be a non-empty path.")
14411 return {
"source_case": source_case.strip()}
14414 if "velocity_scale" not in ic:
14415 raise ValueError(
"initial_conditions.length_scale requires velocity_scale.")
14416 provenance = {
"velocity_scale":
_to_finite_float(ic[
"velocity_scale"],
"initial_conditions.velocity_scale"),
14417 "length_scale": 1.0}
14418 if field_code == 1:
14419 if "length_scale" not in ic:
14420 raise ValueError(
"initial_conditions.length_scale is required for a Ucont payload in solver units.")
14421 provenance[
"length_scale"] =
_to_finite_float(ic[
"length_scale"],
"initial_conditions.length_scale")
14422 elif "length_scale" in ic:
14423 raise ValueError(
"initial_conditions.length_scale applies only to a Ucont payload.")
14424 if provenance[
"velocity_scale"] <= 0.0
or provenance[
"length_scale"] <= 0.0:
14425 raise ValueError(
"initial_conditions.velocity_scale and length_scale must be positive.")
14431 @brief Return the factor a file-backed initial condition is divided by when staged.
14432 @details A physical payload is divided by its dimension's reference scale. A payload in
14433 another run's solver units is first returned to physical units by that run's
14434 scale, so the two factors combine into one ratio.
14435 @param[in] resolved_ic Resolved `mode: file` initial condition.
14436 @param[in] case_dir Directory that relative `source_case` paths resolve against.
14437 @return Positive divisor; 1.0 when the payload already matches this case's solver units.
14439 dimension = IC_PAYLOAD_DIMENSIONS[resolved_ic[
"field_code"]]
14440 provenance = resolved_ic.get(
"payload_provenance")
or {}
14441 source_scales = {
"length_ref": 1.0,
"velocity_ref": 1.0}
14442 if "source_case" in provenance:
14446 source_scales = {
"length_ref": provenance[
"length_scale"],
"velocity_ref": provenance[
"velocity_scale"]}
14449GENERATED_IC_PROVIDERS = {
14450 "channel_spectral_velocity": {
"requires_grid":
True,
"requires_fresh_3d":
True},
14451 "duct_spectral_velocity": {
"requires_grid":
True,
"requires_fresh_3d":
True},
14452 "stability_mode_velocity": {
"requires_grid":
True},
14454 "requires_grid":
True,
14455 "diagnostic_artifacts": (),
14457 "spectral_random_velocity": {
14458 "requires_grid":
True,
14459 "requires_periodic_geometric":
True,
14460 "requires_fresh_3d":
True,
14461 "diagnostic_artifacts": (
14462 (
"summary_json", os.path.join(CANONICAL_RUN_PATHS[
"metrics"],
"initial_condition_summary.json")),
14463 (
"spectrum_csv", INITIAL_CONDITION_SPECTRUM_RELPATH),
14467GENERATED_IC_PROVIDERS[
"resampled_velocity"] = GENERATED_IC_PROVIDERS[
"spectral_random_velocity"]
14472 @brief Return whether a resolved IC is backed by a registered file generator.
14473 @param[in] resolved_ic Resolved initial-condition contract.
14474 @return True for a registered generated-file provider.
14476 return resolved_ic.get(
"kind")
in GENERATED_IC_PROVIDERS
14481 @brief Resolve the shared physical and nondimensional fluid scaling contract.
14482 @param[in] case_cfg Parsed case configuration.
14483 @return Resolved scaling, viscosity, and Reynolds-number quantities.
14485 properties = case_cfg[
"properties"]
14486 scaling = properties[
"scaling"]
14487 fluid = properties[
"fluid"]
14488 length_ref =
_to_finite_float(scaling[
"length_ref"],
"properties.scaling.length_ref")
14489 velocity_ref =
_to_finite_float(scaling[
"velocity_ref"],
"properties.scaling.velocity_ref")
14491 dynamic_viscosity =
_to_finite_float(fluid[
"viscosity"],
"properties.fluid.viscosity")
14492 if length_ref <= 0.0
or velocity_ref <= 0.0
or density <= 0.0
or dynamic_viscosity < 0.0:
14493 raise ValueError(
"length_ref, velocity_ref, and density must be positive; viscosity must be non-negative.")
14494 reynolds = density*velocity_ref*length_ref/dynamic_viscosity
if dynamic_viscosity
else float(
"inf")
14496 "length_ref": length_ref,
"velocity_ref": velocity_ref,
"density": density,
14497 "dynamic_viscosity": dynamic_viscosity,
"reynolds": reynolds,
14498 "physical_kinematic_viscosity": dynamic_viscosity/density,
14499 "nondimensional_kinematic_viscosity": dynamic_viscosity/(density*velocity_ref*length_ref),
14505 @brief Load one of the extensionless scripts under `generators/` as a module.
14506 @param[in] script_name File name under `generators/`, e.g. `ic.gen`.
14507 @param[in] module_name Name to register the loaded module under.
14508 @return Loaded module.
14510 from importlib.machinery
import SourceFileLoader
14511 loader = SourceFileLoader(module_name, os.path.join(GENERATORS_PATH, script_name))
14512 spec = importlib.util.spec_from_loader(loader.name, loader)
14513 module = importlib.util.module_from_spec(spec)
14514 loader.exec_module(module)
14520 @brief Load `generators/ic.gen`, which owns the expression language and the IC providers.
14521 @return Loaded module.
14530PARTICLE_SETTABLE_FIELDS = {
"Psi": 1}
14535 @brief Validate `models.physics.particles.fields` and lower each value to expressions.
14536 @details A value is a number, an expression, or a mapping with `params` and either
14537 `value` or `background` with `regions`; a field with several components takes a
14538 list of values, one per component. Expressions use physical coordinates
14539 `x y z`, domain-normalized `xn yn zn`, `pid`, and physical time `t`, and may
14540 draw with `uniform()`/`normal()`.
14541 @param[in] fields_cfg The `fields` mapping.
14542 @return List of `{"name", "expressions"}` in configuration order.
14543 @throws ValueError on an unknown field, a wrong component count, or an invalid value.
14545 if not isinstance(fields_cfg, dict)
or not fields_cfg:
14546 raise ValueError(
"models.physics.particles.fields must be a non-empty mapping of field names to values.")
14549 for name, spec
in fields_cfg.items():
14550 if name
not in PARTICLE_SETTABLE_FIELDS:
14552 f
"models.physics.particles.fields.{name}: not a field a case can set; the runtime "
14553 f
"re-derives it. Settable fields: {sorted(PARTICLE_SETTABLE_FIELDS)}.")
14554 components = PARTICLE_SETTABLE_FIELDS[name]
14555 specs = spec
if components > 1
else [spec]
14556 if not isinstance(specs, list)
or len(specs) != components:
14557 raise ValueError(f
"models.physics.particles.fields.{name} takes {components} component values.")
14559 ic_gen.lower_value(item, ic_gen.PARTICLE_EXPRESSION_NAMES, random=
True,
14560 where=f
"models.physics.particles.fields.{name}")
14563 normalized.append({
"name": name,
"expressions": expressions})
14569 @brief Convert a provider's validated physical parameters to solver units.
14570 @details Providers work on the staged nondimensional grid, so every dimensional
14571 parameter recorded in `IC_PARAM_QUANTITIES` is converted before the provider
14572 runs. Nested mappings are addressed by dotted name.
14573 @param[in] generator Provider identifier.
14574 @param[in] params Validated parameter mapping in physical units.
14575 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
14576 @return A copy of the mapping with dimensional values in solver units.
14578 converted = copy.deepcopy(params)
14579 for name, quantity
in IC_PARAM_QUANTITIES[generator].items():
14580 if quantity[1] !=
"cli":
14582 *parents, leaf = name.split(
".")
14584 for parent
in parents:
14585 holder = holder.get(parent)
if isinstance(holder, dict)
else None
14586 if isinstance(holder, dict)
and leaf
in holder:
14588 holder[leaf], quantity, scales, f
"initial_conditions.params.{name}")
14594 @brief Validate `resampled_velocity` parameters and convert them to the provider contract.
14595 @details The provider brings an external triply periodic velocity field onto the case's
14596 grid. Its box must be the case's box; the source's container, layout, and sample
14597 positions are declared because no standard format records them all.
14598 @param[in] params Raw provider parameters, physical units.
14599 @param[in] prepared_blocks Normalized boundary-condition blocks.
14600 @param[in] scales Reference scales.
14601 @param[in] provider_context Optional conductor-derived provider context.
14602 @return Resolved initial-condition contract; `source_file` stays as written and is
14603 resolved against the workspace when the provider runs.
14605 name =
"resampled_velocity"
14606 if prepared_blocks
and len(prepared_blocks) != 1:
14607 raise ValueError(f
"{name} requires exactly one grid block.")
14608 if not prepared_blocks
or len(prepared_blocks[0]) != 6
or any(
14609 bc.get(
"type") !=
"PERIODIC" or bc.get(
"handler") !=
"geometric" for bc
in prepared_blocks[0]
14611 raise ValueError(f
"{name} requires PERIODIC/geometric boundaries on all six faces; "
14612 "wall-bounded sources are not supported yet.")
14613 unknown = sorted(set(params) - RESAMPLED_VELOCITY_PARAMS)
14615 raise ValueError(f
"{name} has unsupported params: {unknown}.")
14620 source_file = params.get(
"source_file")
14621 if not isinstance(source_file, str)
or not source_file.strip():
14622 raise ValueError(f
"{name} requires params.source_file, the field to import.")
14623 fmt = str(params.get(
"format",
"")).lower()
14624 if fmt
not in RESAMPLED_VELOCITY_LAYOUT_KEYS:
14625 raise ValueError(f
"{name} params.format must be one of {sorted(RESAMPLED_VELOCITY_LAYOUT_KEYS)}.")
14626 cells = params.get(
"cells")
14627 if cells
is not None:
14628 if (
not isinstance(cells, list)
or len(cells) != 3
14629 or any(isinstance(c, bool)
or not isinstance(c, int)
or c < 4
for c
in cells)):
14630 raise ValueError(f
"{name} params.cells must list the source's sample counts along x, y, z, "
14631 "each an integer of at least 4.")
14633 raise ValueError(f
"{name} with format: raw requires params.cells, the sample counts along x, y, z.")
14634 box = params.get(
"box_length")
14635 if not isinstance(box, list)
or len(box) != 3:
14636 raise ValueError(f
"{name} requires params.box_length: the source's periods along x, y, z, "
14637 "in physical length.")
14638 box = [
_to_finite_float(value, f
"initial_conditions.params.box_length[{index}]")
14639 for index, value
in enumerate(box)]
14640 if any(value <= 0
for value
in box):
14641 raise ValueError(f
"{name} params.box_length values must be positive.")
14642 offset = params.get(
"sample_offset", 0.0)
14643 offset = offset
if isinstance(offset, list)
else [offset, offset, offset]
14644 if len(offset) != 3:
14645 raise ValueError(f
"{name} params.sample_offset is one number or three, along x, y, z.")
14646 offset = [
_to_finite_float(value, f
"initial_conditions.params.sample_offset[{index}]")
14647 for index, value
in enumerate(offset)]
14648 if any(value < 0.0
or value >= 1.0
for value
in offset):
14649 raise ValueError(f
"{name} params.sample_offset is a fraction of the source spacing in [0, 1): "
14650 "0 for samples on the box origin, 0.5 for cell centres.")
14651 layout = params.get(
"layout", {})
14652 if not isinstance(layout, dict):
14653 raise ValueError(f
"{name} params.layout must be a mapping.")
14654 extra = sorted(set(layout) - RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt])
14656 raise ValueError(f
"{name} layout keys {extra} do not apply to format: {fmt}; "
14657 f
"it accepts {sorted(RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt])}.")
14658 normalized_layout = {
"fastest_axis": str(layout.get(
"fastest_axis",
"x")).lower(),
14659 "components": str(layout.get(
"components",
"interleaved")).lower()}
14660 if normalized_layout[
"fastest_axis"]
not in RESAMPLED_VELOCITY_FASTEST_AXES:
14661 raise ValueError(f
"{name} layout.fastest_axis must be x (x varies fastest) or z.")
14662 if normalized_layout[
"components"]
not in RESAMPLED_VELOCITY_COMPONENT_LAYOUTS:
14663 raise ValueError(f
"{name} layout.components must be interleaved (u, v, w at each point) "
14664 "or separate (all u, then all v, then all w).")
14666 dtype = str(layout.get(
"dtype",
"")).lower()
14667 if dtype
not in RESAMPLED_VELOCITY_RAW_DTYPES:
14668 raise ValueError(f
"{name} format: raw requires layout.dtype: float32 or float64.")
14669 byte_order = str(layout.get(
"byte_order",
"native")).lower()
14670 if byte_order
not in RESAMPLED_VELOCITY_BYTE_ORDERS:
14671 raise ValueError(f
"{name} layout.byte_order must be little, big, or native.")
14672 header = layout.get(
"header_bytes", 0)
14673 if isinstance(header, bool)
or not isinstance(header, int)
or header < 0:
14674 raise ValueError(f
"{name} layout.header_bytes must be a non-negative integer.")
14675 normalized_layout.update({
"dtype": dtype,
"byte_order": byte_order,
"header_bytes": header})
14676 elif "datasets" in RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt]:
14677 datasets = layout.get(
"datasets")
14678 if (
not isinstance(datasets, list)
or len(datasets)
not in (1, 3)
14679 or any(
not isinstance(item, str)
or not item
for item
in datasets)):
14680 raise ValueError(f
"{name} format: {fmt} requires layout.datasets: one array name, "
14681 "or three for u, v, w.")
14682 normalized_layout[
"datasets"] =
list(datasets)
14683 filter_cfg = params.get(
"filter", {
"type":
"none"})
14684 if not isinstance(filter_cfg, dict):
14685 raise ValueError(f
"{name} params.filter must be a mapping.")
14686 filter_type = str(filter_cfg.get(
"type",
"none")).lower()
14687 expected = {
"none": set(),
"sharp": {
"cutoff"},
"gaussian": {
"width"},
"box": {
"width"}}
14688 if filter_type
not in expected:
14689 raise ValueError(f
"{name} filter.type must be one of {sorted(expected)}.")
14690 if set(filter_cfg) - {
"type"} != expected[filter_type]:
14691 raise ValueError(f
"{name} filter.type: {filter_type} takes exactly "
14692 f
"{sorted(expected[filter_type]) or 'no parameters'}.")
14693 normalized_filter = {
"type": filter_type}
14694 for key
in expected[filter_type]:
14695 value =
_to_finite_float(filter_cfg[key], f
"initial_conditions.params.filter.{key}")
14697 raise ValueError(f
"{name} filter.{key} must be positive.")
14698 normalized_filter[key] = value
14699 projection = str(params.get(
"projection",
"picurv_discrete")).lower()
14700 if projection
not in RESAMPLED_VELOCITY_PROJECTIONS:
14701 raise ValueError(f
"{name} params.projection must be picurv_discrete, continuum, or none.")
14702 remove_mean = params.get(
"remove_mean",
True)
14703 if not isinstance(remove_mean, bool):
14704 raise ValueError(f
"{name} params.remove_mean must be boolean.")
14705 normalized = {
"field":
"Ucont" if field_code == 1
else "Ucat",
"source_file": source_file,
14706 "format": fmt,
"box_length": box,
14707 "sample_offset": offset,
"layout": normalized_layout,
"filter": normalized_filter,
14708 "projection": projection,
"remove_mean": remove_mean}
14709 if cells
is not None:
14710 normalized[
"cells"] =
list(cells)
14712 resolved[
"velocity_divisor"] = float(scales[
"velocity_ref"])
14713 return {
"finit": 4,
"cli_params": {},
"kind": name,
"label": name,
14714 "field_name": field_name,
"field_code": field_code,
14715 "provider_context": dict(provider_context
or {}),
"params": resolved}
14720 @brief Validate the spectra a structured IC provider measures on its own output.
14721 @param[in] requested `params.initial_spectra` as written, or None.
14722 @param[in] excluded_axes Axes the spectra must not transform (walls, cross-stream).
14723 @param[in] generator Provider identifier, for messages.
14724 @param[in] required Whether a nonempty list is mandatory.
14725 @return Normalized spectrum tasks (an empty list when optional and absent).
14726 @throws ValueError on an absent required list or an unsupported task.
14728 if requested
is None and not required:
14730 if not isinstance(requested, list)
or not requested:
14731 raise ValueError(f
"{generator} requires params.initial_spectra: a nonempty list of "
14732 "plane_spectrum or line_spectrum tasks over the periodic axes.")
14734 for task
in spectra[
"tasks"]:
14735 if task[
"task"] ==
"shell_spectrum" or set(task[
"axes"]) & set(excluded_axes):
14736 raise ValueError(f
"initial spectra must transform only periodic axes of the {generator} IC.")
14737 if task[
"block"] != 0
or task[
"subtract_mean"].startswith(
"window:")
or task[
"reference"]:
14738 raise ValueError(
"initial spectra require block 0, no window mean, and no reference.")
14739 return spectra[
"tasks"]
14744 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
14745 @details Every value the user writes is physical; the resolved contract carries solver
14746 units, or the scales a provider or the staging step needs to reach them.
14747 @param[in] ic Initial-condition YAML mapping.
14748 @param[in] prepared_blocks Normalized boundary-condition blocks.
14749 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
14750 @param[in] provider_context Optional conductor-derived provider context.
14751 @return Normalized launcher initial-condition contract.
14753 if not isinstance(ic, dict):
14754 raise ValueError(
"properties.initial_conditions must be a mapping.")
14755 mode = str(ic.get(
"mode",
"")).strip()
14758 if mode
in LEGACY_FIELD_INIT_SPELLINGS:
14761 if finit_code == 1
and params.pop(
"ic_coordinate_system", 0) == 1:
14763 return {
"finit": finit_code,
"cli_params": params,
"kind":
"builtin",
"label": mode}
14765 normalized_mode = mode.lower().replace(
"-",
"_").replace(
" ",
"_")
14766 if normalized_mode ==
"file":
14767 if prepared_blocks
and len(prepared_blocks) > 1:
14768 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
14769 source_file = ic.get(
"source_file")
14770 if not isinstance(source_file, str)
or not source_file.strip():
14771 raise ValueError(
"initial_conditions.source_file is required when mode is 'file'.")
14774 "finit": 4,
"cli_params": {},
"kind":
"file",
"label":
"file",
14775 "source_file": source_file.strip(),
"field_name": field_name,
"field_code": field_code,
14777 "scales": dict(scales),
14779 if normalized_mode !=
"generated":
14780 raise ValueError(
"initial_conditions.mode must be 'generated' or 'file'.")
14782 generator = str(ic.get(
"generator",
"")).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
14783 params = ic.get(
"params", {})
14784 if not isinstance(params, dict):
14785 raise ValueError(
"initial_conditions.params must be a mapping.")
14786 for key
in RETIRED_INITIAL_CONDITION_DESTINATION_KEYS:
14789 if generator ==
"ic_gen":
14790 if prepared_blocks
and len(prepared_blocks) > 1:
14791 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
14792 script = params.get(
"script")
14793 if script
is not None and (
not isinstance(script, str)
or not script.strip()):
14794 raise ValueError(
"initial_conditions.params.script must be a non-empty path when provided.")
14796 config_file = params.get(
"config_file")
14797 if not isinstance(config_file, str)
or not config_file.strip():
14798 raise ValueError(
"initial_conditions.params.config_file is required for generator 'ic_gen'.")
14799 cli_args = params.get(
"cli_args", [])
14800 if cli_args
is None:
14802 if not isinstance(cli_args, list):
14803 raise ValueError(
"initial_conditions.params.cli_args must be a list.")
14805 "finit": 4,
"cli_params": {},
"kind":
"ic_gen",
"label":
"ic_gen",
14806 "field_name": field_name,
"field_code": field_code,
14807 "config_file": config_file.strip(),
14808 "script": script.strip()
if script
is not None else None,
14809 "cli_args": cli_args,
14810 "scales": dict(scales),
14813 if generator
in (
"channel_spectral_velocity",
"duct_spectral_velocity"):
14815 if not prepared_blocks
or len(prepared_blocks) != 1:
14816 raise ValueError(
"wall spectral IC requires exactly one block with resolved boundaries.")
14817 face_axes = {
"Xi":
"i",
"Eta":
"j",
"Zeta":
"k"}
14818 faces = prepared_blocks[0]
14819 if len(faces) != 6:
14820 raise ValueError(
"wall spectral IC requires all six boundary faces.")
14822 axis = face_axes[bc[
"face"][1:]]
14823 if axis
in normalized[
"wall_axes"]:
14825 valid = bc[
"type"] ==
"WALL" and "WALL" in BC_HANDLER_SPECS.get(bc[
"handler"], {}).get(
"types", ())
14827 valid = bc[
"type"] ==
"PERIODIC" and bc[
"handler"]
in (
"geometric",
"constant_flux",
"initial_flux")
14828 if axis != normalized[
"streamwise_axis"]:
14829 valid = valid
and bc[
"handler"] ==
"geometric"
14831 raise ValueError(
"wall spectral IC requires wall pairs (no-slip or free-slip) and periodic remaining axes, driven only streamwise.")
14833 normalized.get(
"initial_spectra"), normalized[
"wall_axes"], generator, required=
True)
14834 return {
"finit": 4,
"cli_params": {},
"kind": generator,
"label": generator,
14835 "field_name":
"ufield",
"field_code": 0,
14837 "provider_context": dict(provider_context
or {})}
14839 if generator ==
"stability_mode_velocity":
14841 if not prepared_blocks
or len(prepared_blocks) != 1:
14842 raise ValueError(
"stability_mode_velocity requires exactly one block with resolved boundaries.")
14843 face_axes = {
"Xi":
"i",
"Eta":
"j",
"Zeta":
"k"}
14844 faces = prepared_blocks[0]
14845 if len(faces) != 6:
14846 raise ValueError(
"stability_mode_velocity requires all six boundary faces.")
14848 cross = face_axes[bc[
"face"][1:]] == normalized[
"cross_axis"]
14849 if cross == (bc[
"type"] ==
"PERIODIC")
or (
not cross
and bc[
"handler"] !=
"geometric"):
14850 raise ValueError(
"stability_mode_velocity requires geometric periodic streamwise and normal axes "
14851 "and non-periodic cross-stream faces.")
14852 if "initial_spectra" in normalized:
14854 normalized[
"initial_spectra"], [normalized[
"cross_axis"]], generator, required=
False)
14855 context = dict(provider_context
or {})
14857 context.update({
"length_ref": scales[
"length_ref"],
"velocity_ref": scales[
"velocity_ref"]})
14858 return {
"finit": 4,
"cli_params": {},
"kind": generator,
"label": generator,
14859 "field_name":
"ufield",
"field_code": 0,
14861 "provider_context": context}
14863 if generator ==
"resampled_velocity":
14866 if generator ==
"spectral_random_velocity":
14867 if prepared_blocks
and len(prepared_blocks) != 1:
14868 raise ValueError(
"spectral_random_velocity requires exactly one grid block.")
14869 if not prepared_blocks
or len(prepared_blocks[0]) != 6
or any(
14870 bc.get(
"type") !=
"PERIODIC" or bc.get(
"handler") !=
"geometric"
14871 for bc
in prepared_blocks[0]
14873 raise ValueError(
"spectral_random_velocity requires PERIODIC/geometric boundaries on all six faces.")
14874 unknown = sorted(set(params) - SPECTRAL_RANDOM_VELOCITY_PARAMS)
14876 raise ValueError(f
"spectral_random_velocity has unsupported params: {unknown}.")
14878 if field_code != 0:
14879 raise ValueError(
"spectral_random_velocity supports only params.field: Ucat.")
14880 seed = params.get(
"seed", 12345)
14881 if isinstance(seed, bool)
or not isinstance(seed, int):
14882 raise ValueError(
"spectral_random_velocity params.seed must be an integer.")
14883 random_cfg = params.get(
"random", {})
14884 if not isinstance(random_cfg, dict)
or set(random_cfg) - {
"distribution",
"mean"}:
14885 raise ValueError(
"spectral_random_velocity params.random supports only distribution and mean.")
14886 distribution = str(random_cfg.get(
"distribution",
"gaussian")).lower()
14887 if distribution !=
"gaussian":
14888 raise ValueError(
"spectral_random_velocity supports only random.distribution: gaussian.")
14889 mean = random_cfg.get(
"mean", [0.0, 0.0, 0.0])
14890 if not isinstance(mean, list)
or len(mean) != 3:
14891 raise ValueError(
"spectral_random_velocity random.mean must be a three-component list.")
14892 mean = [
_to_finite_float(value, f
"initial_conditions.params.random.mean[{index}]")
14893 for index, value
in enumerate(mean)]
14894 spectrum = params.get(
"spectrum", {})
14895 if not isinstance(spectrum, dict):
14896 raise ValueError(
"spectral_random_velocity params.spectrum must be a mapping.")
14897 spectrum_type = str(spectrum.get(
"type",
"white")).lower()
14898 if spectrum_type ==
"white":
14899 if set(spectrum) - {
"type"}:
14900 raise ValueError(
"white spectrum supports only type.")
14901 normalized_spectrum = {
"type":
"white"}
14902 elif spectrum_type ==
"k4_exponential":
14903 if set(spectrum) - {
"type",
"k0",
"k_cut"}
or "k0" not in spectrum
or "k_cut" not in spectrum:
14904 raise ValueError(
"k4_exponential spectrum requires only type, k0, and k_cut.")
14905 normalized_spectrum = {
"type":
"k4_exponential",
14906 "k0":
_to_finite_float(spectrum[
"k0"],
"initial_conditions.params.spectrum.k0"),
14907 "k_cut":
_to_finite_float(spectrum[
"k_cut"],
"initial_conditions.params.spectrum.k_cut")}
14909 raise ValueError(
"spectrum.type must be 'white' or 'k4_exponential'.")
14910 if any(value <= 0
for key, value
in normalized_spectrum.items()
if key !=
"type"):
14911 raise ValueError(
"spectrum k0 and k_cut values must be positive.")
14912 projection = params.get(
"projection", {
"type":
"none"})
14913 if not isinstance(projection, dict)
or set(projection) - {
"type",
"operator"}:
14914 raise ValueError(
"projection supports only type and operator.")
14915 projection_type = str(projection.get(
"type",
"none")).lower()
14916 if projection_type ==
"none" and "operator" not in projection:
14917 normalized_projection = {
"type":
"none"}
14918 elif projection_type ==
"solenoidal" and str(projection.get(
"operator",
"")).lower()
in PROJECTION_OPERATORS:
14919 normalized_projection = {
"type":
"solenoidal",
"operator": str(projection[
"operator"]).lower()}
14921 raise ValueError(
"projection must be none, or solenoidal with continuum/picurv_discrete operator.")
14922 normalization = params.get(
"normalization", {
"type":
"none"})
14923 if not isinstance(normalization, dict)
or set(normalization) - {
"type",
"target"}:
14924 raise ValueError(
"normalization supports only type and target.")
14925 normalization_type = str(normalization.get(
"type",
"none")).lower()
14926 if normalization_type ==
"none" and "target" not in normalization:
14927 normalized_normalization = {
"type":
"none"}
14928 elif normalization_type ==
"component_rms" and "target" in normalization:
14929 target =
_to_finite_float(normalization[
"target"],
"initial_conditions.params.normalization.target")
14931 raise ValueError(
"component_rms normalization.target must be positive.")
14932 normalized_normalization = {
"type":
"component_rms",
"target": target}
14934 raise ValueError(
"normalization must be none or component_rms with target.")
14935 remove_mean = params.get(
"remove_mean",
True)
14936 if not isinstance(remove_mean, bool):
14937 raise ValueError(
"spectral_random_velocity remove_mean must be boolean.")
14938 context = dict(provider_context
or {})
14940 "finit": 4,
"cli_params": {},
"kind":
"spectral_random_velocity",
"label":
"spectral_random_velocity",
14941 "field_name": field_name,
"field_code": field_code,
"provider_context": context,
14943 "spectral_random_velocity",
14944 {
"field":
"Ucat",
"seed": seed,
14945 "random": {
"distribution": distribution,
"mean": mean},
14946 "spectrum": normalized_spectrum,
"projection": normalized_projection,
14947 "normalization": normalized_normalization,
"remove_mean": remove_mean},
14951 generator_modes = {
14952 "zero": (0,
"Zero"),
14953 "constant": (1,
"Constant"),
14954 "streamwise_constant": (3,
"Constant"),
14955 "poiseuille": (2,
"Poiseuille"),
14957 if generator
not in generator_modes:
14959 "initial_conditions.generator must be one of: zero, constant, "
14960 "streamwise_constant, poiseuille, ic_gen, spectral_random_velocity, resampled_velocity, "
14961 "channel_spectral_velocity, duct_spectral_velocity, stability_mode_velocity."
14963 finit_code, legacy_mode = generator_modes[generator]
14964 legacy_ic = dict(params)
14965 legacy_ic[
"mode"] = legacy_mode
14968 1
if finit_code == 3
else finit_code,
14972 cli_params.pop(
"ic_coordinate_system",
None)
14973 return {
"finit": finit_code,
"cli_params": cli_params,
"kind":
"builtin",
"label": generator}
14977 @brief Bytes of a PetscInt in the PETSc the solver this conductor drives was built on.
14978 @details PETSc binary files store their sizes as PetscInt and do not record the width, so
14979 a file staged for the solver must use the solver's (PETSc's own PetscBinaryIO
14980 reads it from the build configuration the same way). An unbuilt or unstamped
14981 solver counts as 32-bit, PETSc's default.
14984 if _SELECTED_PETSC_INDEX_BYTES
is not None:
14985 return _SELECTED_PETSC_INDEX_BYTES
14987 return PETSC_INDEX_BYTES.get(petsc.get(
"indices",
"int32"), 4)
14991PETSC_INDEX_BITS_CHOICES = (
"32",
"64")
14993PETSC_MAX_INT_32BIT = 2**31 - 1
14996PARTICLE_FIELD_MAX_COMPONENTS = 3
14997GRID_FIELD_MAX_COMPONENTS = 3
14998STATISTICS_FIELD_COMPONENTS = 6
15003 @brief The index width a command was told to use: `--petsc-index-bits`, else `PICURV_PETSC_INDEX_BITS`.
15004 @param[in] args Parsed command arguments, or None.
15005 @return 4, 8, or None when neither names one.
15007 bits = getattr(args,
"petsc_index_bits",
None)
if args
is not None else None
15008 bits = bits
or os.environ.get(
"PICURV_PETSC_INDEX_BITS")
or None
15011 if str(bits).strip()
not in PETSC_INDEX_BITS_CHOICES:
15012 raise ValueError(f
"PETSc index bits must be 32 or 64, not {bits!r}.")
15013 return 4
if str(bits).strip() ==
"32" else 8
15017 staged_grid:
"str | None" =
None) ->
"tuple[int, str]":
15019 @brief The PetscInt width a new run needs, from its particle count and grid size.
15020 @details One PETSc object of more than PETSC_MAX_INT_32BIT entries needs 64-bit indices:
15021 a particle field (particles x 3) or a grid field (DA nodes x 3, or x 6 with field
15022 statistics). The solver checks the same limits when it creates them, so an
15023 underestimate here is refused at setup rather than run.
15024 @param[in] case_cfg Parsed case configuration.
15025 @param[in] case_path Path to case.yml.
15026 @param[in] monitor_cfg Parsed monitor configuration, for field statistics.
15027 @param[in] staged_grid Staged PICGRID, if any.
15028 @return (4 or 8, reason).
15030 particles = (((case_cfg.get(
"models")
or {}).get(
"physics")
or {}).get(
"particles")
or {})
15031 particle_entries = int(particles.get(
"count", 0)
or 0) * PARTICLE_FIELD_MAX_COMPONENTS
15033 components = STATISTICS_FIELD_COMPONENTS
if statistics
else GRID_FIELD_MAX_COMPONENTS
15034 grid_entries = max([(im + 1) * (jm + 1) * (km + 1) * components
15036 if particle_entries > PETSC_MAX_INT_32BIT:
15037 return 8, f
"{particle_entries:,} particle-field entries exceed the 32-bit limit {PETSC_MAX_INT_32BIT:,}"
15038 if grid_entries > PETSC_MAX_INT_32BIT:
15039 return 8, f
"{grid_entries:,} grid-field entries exceed the 32-bit limit {PETSC_MAX_INT_32BIT:,}"
15040 return 4,
"particle and grid fields fit 32-bit indices"
15045 @brief PetscInt width the committed checkpoints under a run output root were written with.
15046 @details Every checkpoint of a run shares its build's width; one written before the width
15047 was recorded is 32-bit.
15048 @param[in] source_dir Run output root holding `checkpoints/`.
15049 @return 4 or 8, or None when it holds no committed checkpoint.
15051 root = os.path.join(os.path.abspath(source_dir),
"checkpoints")
15052 for name
in sorted(os.listdir(root), reverse=
True)
if os.path.isdir(root)
else []:
15053 metadata = os.path.join(root, name,
"checkpoint.meta")
15054 if os.path.isfile(os.path.join(root, name,
"COMMITTED"))
and os.path.isfile(metadata):
15061 @brief Make the current command drive the build of one PetscInt width.
15062 @details `resolve_runtime_executable()` and `runtime_petsc_index_bytes()` follow it, so the
15063 executables staged, pinned and launched, and every PETSc file the conductor writes
15064 or validates for them, agree. Refuses a width whose executables are not built.
15065 @param[in] index_bytes 4 or 8.
15066 @param[in] reason Why, for the message.
15067 @return index_bytes.
15069 global _SELECTED_PETSC_INDEX_BYTES
15070 _SELECTED_PETSC_INDEX_BYTES = index_bytes
15071 if index_bytes == 8:
15073 if not os.path.isfile(path)]
15076 f
"This run needs the 64-bit-index build ({reason}), which is not built: missing "
15077 f
"{', '.join(missing)}. Build it with `make INDEX_BITS=64 PETSC_DIR=<64-bit-index PETSc> "
15078 f
"[PETSC_ARCH=...] all`.")
15080 print(f
"[INFO] Using the 64-bit-index build: {reason}.", file=sys.stderr)
15085 restart_source_dir:
"str | None" =
None) -> int:
15087 @brief Choose and select the PetscInt width a run's solve stage needs.
15088 @details A restart or continuation keeps the width its source checkpoint was written with;
15089 a new run uses 32-bit unless a particle or grid field outgrows it. An explicit
15090 `--petsc-index-bits` or `PICURV_PETSC_INDEX_BITS` wins, except against a checkpoint.
15091 @param[in] args Parsed command arguments.
15092 @param[in] case_cfg Parsed case configuration.
15093 @param[in] case_path Path to case.yml.
15094 @param[in] monitor_cfg Parsed monitor configuration.
15095 @param[in] restart_source_dir Restart source output root, if any.
15099 if restart_source_dir:
15101 if recorded
is not None:
15102 if requested
is not None and requested != recorded:
15104 f
"--petsc-index-bits {8 * requested} conflicts with the restart source {restart_source_dir}, "
15105 f
"whose checkpoints were written with {8 * recorded}-bit indices.")
15108 if requested == 4
and needed == 8:
15109 raise ValueError(f
"--petsc-index-bits 32 cannot run this case: {reason}.")
15110 if requested
is not None:
15117 @brief Select the PetscInt width an existing run was staged with, for its later stages.
15118 @details The width staging recorded in `config/active.json`, else its checkpoints' width,
15119 else 32-bit. A conflicting explicit request is refused.
15120 @param[in] run_dir Run directory.
15121 @param[in] args Parsed command arguments, or None.
15124 active =
_read_json_if_exists(os.path.join(os.path.abspath(run_dir),
"config",
"active.json"))
or {}
15125 recorded = active.get(
"petsc_index_bytes")
15126 if recorded
is None:
15129 if recorded
is not None and requested
is not None and requested != int(recorded):
15130 raise ValueError(f
"--petsc-index-bits {8 * requested} conflicts with {run_dir}, staged with "
15131 f
"{8 * int(recorded)}-bit indices.")
15132 width = int(recorded)
if recorded
is not None else (requested
or 4)
15138 @brief Record in `config/active.json` the PetscInt width the current command selected.
15139 @param[in] run_dir Run directory.
15142 active_path = os.path.join(os.path.abspath(run_dir),
"config",
"active.json")
15144 if isinstance(active, dict):
15145 active[
"petsc_index_bytes"] = _SELECTED_PETSC_INDEX_BYTES
or 4
15151 @brief PetscInt width of a PETSc payload: as its checkpoint recorded it, else the driven solver's.
15152 @details A committed checkpoint's `checkpoint.meta` records the width it was written with
15153 (`-checkpoint_petsc_index_bytes`; absent in checkpoints written before it existed,
15154 which are 32-bit). A payload outside any checkpoint is read as the driven solver
15156 @param[in] path Payload path.
15159 directory = os.path.dirname(os.path.abspath(path))
15161 metadata = os.path.join(directory,
"checkpoint.meta")
15162 if os.path.isfile(metadata):
15164 directory = os.path.dirname(directory)
15170 @brief Generator arguments naming a PetscInt width; none for PETSc's default 32-bit.
15171 @details Kept empty at 4 bytes so a user's replacement generator script that predates the
15172 option keeps working for every 32-bit build.
15173 @param[in] index_bytes 4 or 8.
15174 @return Argument list.
15176 return []
if index_bytes == 4
else [
"--petsc-index-bytes", str(index_bytes)]
15181 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
15182 @param[in] path PETSc binary vector path.
15183 @param[in] index_bytes PetscInt width of the reading build; the driven solver's when None.
15184 @return Summary containing the absolute path, scalar count and index width.
15188 header_bytes = 2 * index_bytes
15189 with open(path,
"rb")
as fin:
15190 header = fin.read(header_bytes)
15191 if len(header) != header_bytes:
15192 raise ValueError(f
"PETSc Vec file is too short: {path}")
15193 class_id, scalar_count = struct.unpack(
">ii" if index_bytes == 4
else ">qq", header)
15194 payload = fin.read()
15195 if class_id != 1211214
or scalar_count < 0
or len(payload) != scalar_count * 8:
15197 f
"Invalid PETSc Vec file {path} for a PETSc with {index_bytes}-byte indices; a file "
15198 f
"written for the other index width (or truncated) does not fit its header.")
15199 return {
"path": os.path.abspath(path),
"scalar_count": scalar_count,
"index_bytes": index_bytes}
15204 @brief Copy a PETSc binary Vec, dividing every scalar by one factor.
15205 @details Used to stage a physical payload in solver units. The envelope is the one
15206 `validate_petsc_vec_binary()` accepts: a big-endian class id and count of the
15207 solver's index width, followed by big-endian doubles.
15208 @param[in] source Validated PETSc binary vector path.
15209 @param[in] destination Path to write the scaled vector to.
15210 @param[in] divisor Reference scale the payload is divided by.
15211 @return Summary of the destination, as `validate_petsc_vec_binary()` reports it.
15213 from array
import array
15215 with open(source,
"rb")
as fin:
15216 header = fin.read(2 * index_bytes)
15217 values = array(
"d")
15218 values.frombytes(fin.read())
15219 if sys.byteorder ==
"little":
15221 for index, value
in enumerate(values):
15222 values[index] = value / divisor
15223 if sys.byteorder ==
"little":
15225 with open(destination,
"wb")
as fout:
15227 fout.write(values.tobytes())
15232 @brief Resolve spectral IC artifacts through the canonical run layout, also for dry runs.
15233 @param[in] run_dir Run or asset-build root.
15234 @param[in] resolved_ic Normalized file-backed IC provider.
15235 @return Canonical diagnostic artifact paths, including each sample CSV and JSON.
15237 if resolved_ic[
"kind"]
not in SPECTRAL_IC_PROVIDERS:
15239 paths = [os.path.join(run_dir, CANONICAL_RUN_PATHS[
"metrics"],
"initial_condition_summary.json")]
15240 if resolved_ic[
"kind"]
in SHELL_SPECTRUM_IC_PROVIDERS:
15241 return paths + [os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)]
15242 for task
in resolved_ic[
"params"].get(
"initial_spectra", []):
15244 paths.extend([os.path.join(run_dir, CANONICAL_RUN_PATHS[
"spectra"], base+suffix)
for suffix
in (
".csv",
".json")])
15249 spectrum_path: str, case_dir: str, task_cfg=
None) -> str:
15251 @brief Measure the shell-averaged spectrum of a staged initial condition.
15253 The spectrum has a single implementation in `generators/spectra.gen`, so the
15254 conductor measures the generated field rather than asking the initial-condition
15255 generator to report a spectrum it would have to bin itself.
15257 @param[in] field_path Generated PETSc binary Ucat path.
15258 @param[in] staged_grid Staged canonical PICGRID path.
15259 @param[in] spectrum_path Destination `k,energy` CSV path.
15260 @param[in] case_dir Working directory for the subprocess.
15261 @param[in] task_cfg Optional normalized plane/line task; None retains DIT shell measurement.
15262 @return Absolute path to the written spectrum CSV.
15263 @throws ValueError when the generator is missing or fails.
15265 script = os.path.join(GENERATORS_PATH,
"spectra.gen")
15266 if not os.path.isfile(script):
15267 raise ValueError(f
"spectra.gen script not found: {script}")
15268 command = task_cfg[
"task"].replace(
"_",
"-")
if task_cfg
else "shell-spectrum"
15269 cmd = [sys.executable, script, command,
15270 "--field-file", field_path,
"--source-grid", staged_grid,
15273 cmd.extend([
"--axes", *task_cfg[
"axes"],
"--fixed-indices", json.dumps(task_cfg[
"fixed_indices"]),
15274 "--subtract-mean", task_cfg[
"subtract_mean"],
"--summary-json", os.path.splitext(spectrum_path)[0]+
".json"])
15275 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
15276 if result.returncode != 0:
15277 details = (result.stderr
or result.stdout
or "").strip()
15279 f
"initial-condition spectrum failed with exit code {result.returncode}. Details:\n{details}"
15281 return os.path.abspath(spectrum_path)
15286 @brief Run the repository IC generator.
15287 @param[in] case_path Source case YAML path.
15288 @param[in] run_dir Run or precompute output directory.
15289 @param[in] resolved_ic Normalized external-generator contract.
15290 @return Generated PETSc vector path.
15292 case_dir = os.path.dirname(os.path.abspath(case_path))
15293 if resolved_ic[
"kind"]
in SPECTRAL_IC_PROVIDERS:
15294 script = os.path.join(GENERATORS_PATH,
"ic.gen")
15295 output_path = os.path.join(run_dir,
"inputs",
"initial_condition",
"initial_condition.generated.dat")
15296 os.makedirs(os.path.dirname(output_path), exist_ok=
True)
15297 staged_grid = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
15298 if not os.path.isfile(staged_grid):
15299 raise ValueError(f
"{resolved_ic['kind']} requires a staged PICGRID at inputs/grid/grid.run.")
15300 summary_path = os.path.join(run_dir,
"output",
"analysis",
"metrics",
"initial_condition_summary.json")
15301 spectrum_path = os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)
15302 os.makedirs(os.path.dirname(summary_path), exist_ok=
True)
15303 os.makedirs(os.path.dirname(spectrum_path), exist_ok=
True)
15304 provider_params = resolved_ic[
"params"]
15305 provider_context = dict(resolved_ic.get(
"provider_context", {}))
15306 measured_field = output_path
15307 if resolved_ic[
"kind"] ==
"resampled_velocity" and provider_params.get(
"field") ==
"Ucont":
15310 measured_field = os.path.join(os.path.dirname(output_path),
".runtime_ucat_for_spectrum.dat")
15311 provider_context[
"runtime_ucat_output"] = measured_field
15312 if resolved_ic[
"kind"] ==
"resampled_velocity":
15315 provider_params = dict(provider_params)
15317 if not os.path.isfile(provider_params[
"source_file"]):
15318 raise ValueError(f
"resampled_velocity source file not found: {provider_params['source_file']}")
15319 cmd = [sys.executable, script,
"--generator", resolved_ic[
"kind"],
15320 "--grid", staged_grid,
"--output", output_path,
15321 "--params-json", json.dumps(provider_params, sort_keys=
True),
15322 "--context-json", json.dumps(provider_context, sort_keys=
True),
15324 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
15325 if result.returncode != 0:
15326 details = (result.stderr
or result.stdout
or "").strip()
15327 raise ValueError(f
"{resolved_ic['kind']} failed with exit code {result.returncode}. Details:\n{details}")
15329 if resolved_ic[
"kind"]
in SHELL_SPECTRUM_IC_PROVIDERS:
15333 if measured_field != output_path
and os.path.exists(measured_field):
15334 os.remove(measured_field)
15337 for task, path
in zip(resolved_ic[
"params"].get(
"initial_spectra", []), paths[1::2]):
15341 config_file = resolved_ic[
"config_file"]
15343 if not os.path.isfile(script):
15344 raise ValueError(f
"ic.gen script not found: {script}")
15345 if not os.path.isfile(config_file):
15346 raise ValueError(f
"initial-condition generator config file not found: {config_file}")
15347 output_path = os.path.join(run_dir,
"inputs",
"initial_condition",
"initial_condition.generated.dat")
15348 os.makedirs(os.path.dirname(output_path), exist_ok=
True)
15349 cmd = [sys.executable, script,
"-c", config_file,
"--field",
15350 "Ucat" if resolved_ic[
"field_code"] == 0
else "Ucont",
15352 staged_grid = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
15353 if os.path.isfile(staged_grid):
15354 cmd.extend([
"--grid", staged_grid])
15357 cmd.extend([
"--length-ref", repr(float(resolved_ic[
"scales"][
"length_ref"])),
15358 "--velocity-ref", repr(float(resolved_ic[
"scales"][
"velocity_ref"]))])
15359 cmd.extend(str(token)
for token
in resolved_ic.get(
"cli_args", []))
15360 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
15361 if result.returncode != 0:
15362 details = (result.stderr
or result.stdout
or "").strip()
15363 raise ValueError(f
"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
15369 @brief Refuse a `mode: file` payload sized for another grid, and say what imports one.
15370 @details `mode: file` reads its vector verbatim, so it must already be laid out for this
15371 grid: one value per component per cell including one dummy layer. The runtime
15372 would refuse a mismatch at startup; staging refuses it first and names
15373 `resampled_velocity`, which brings a field from another grid onto this one.
15374 Programmatic grids are not staged here, so they keep the runtime check.
15375 @param[in] payload Header summary from `validate_petsc_vec_binary()`.
15376 @param[in] staged_grid Staged PICGRID path, if the grid is file-backed.
15377 @param[in] resolved_ic Normalized file-backed IC contract.
15379 if not os.path.isfile(staged_grid):
15384 im, jm, km = dims[0]
15385 expected = (im + 1) * (jm + 1) * (km + 1) * 3
15386 if payload[
"scalar_count"] != expected:
15388 f
"Initial-condition file {payload['path']} holds {payload['scalar_count']} values; this grid "
15389 f
"({im - 1}x{jm - 1}x{km - 1} cells) needs {expected} for {resolved_ic.get('field_name', 'the field')}. "
15390 "mode: file reads a field already laid out for this grid. To bring in a field from another "
15391 "grid or format, use generator: resampled_velocity."
15397 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
15398 @param[in] run_dir Run or precompute output directory.
15399 @param[in] case_path Source case YAML path.
15400 @param[in] resolved_ic Normalized file-backed IC contract.
15401 @return Source, staged path, and staging-directory summary.
15403 stage_dir = os.path.join(run_dir,
"inputs",
"initial_condition")
15404 os.makedirs(stage_dir, exist_ok=
True)
15405 staged_path = os.path.join(stage_dir, f
"{resolved_ic['field_name']}00000_0.dat")
15406 if os.path.isfile(staged_path):
15409 "source": os.path.abspath(staged_path),
15410 "staged": os.path.abspath(staged_path),
15411 "directory": os.path.abspath(stage_dir),
15414 if resolved_ic[
"kind"]
in SPECTRAL_IC_PROVIDERS:
15422 case_dir = os.path.dirname(os.path.abspath(case_path))
15424 if not os.path.isfile(source_path):
15425 raise ValueError(f
"Initial-condition source file not found: {source_path}")
15431 elif os.path.abspath(source_path) != os.path.abspath(staged_path):
15432 shutil.copy2(source_path, staged_path)
15433 summary = {
"source": os.path.abspath(source_path),
"staged": os.path.abspath(staged_path),
15434 "directory": os.path.abspath(stage_dir),
"payload_divisor": divisor}
15435 if resolved_ic[
"kind"]
in SPECTRAL_IC_PROVIDERS:
15441 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
15442 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
15443 @return Integer 0-5 matching the FlowDirection enum.
15444 @throws ValueError on unknown value.
15447 "+Xi": 0,
"-Xi": 1,
15448 "+Eta": 2,
"-Eta": 3,
15449 "+Zeta": 4,
"-Zeta": 5,
15450 }.get(str(value).strip())
15453 f
"Unknown initial_conditions.flow_direction '{value}'. "
15454 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
15460 @brief Return True if any prepared BC block contains an INLET face.
15461 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
15462 @return True if at least one INLET entry exists across all blocks.
15464 if not prepared_blocks:
15466 for block_bcs
in prepared_blocks:
15467 for entry
in block_bcs:
15468 if entry.get(
"type") ==
"INLET":
15474 @brief Resolve all IC parameters and return a dict of PETSc option values.
15475 @param[in] ic The properties.initial_conditions mapping.
15476 @param[in] finit_code Normalized -finit integer code.
15477 @param[in] prepared_blocks Normalized BC blocks (may be None).
15478 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
15479 @return Dict with keys matching PETSc option names (without leading dash).
15480 @throws KeyError if a required key is absent.
15481 @throws ValueError on invalid combinations or values.
15485 if finit_code == 0:
15488 if finit_code == 1:
15489 has_cartesian = any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical"))
15490 has_curvilinear =
"velocity_physical" in ic
15492 if has_cartesian
and has_curvilinear:
15494 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
15495 "velocity_physical (curvilinear) — use one or the other."
15498 if has_curvilinear:
15500 result[
"ic_coordinate_system"] = cs_code
15502 vel_phys = float(ic[
"velocity_physical"])
15503 except (TypeError, ValueError)
as exc:
15505 f
"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
15506 "Expected a numeric value."
15509 vel_phys, (
"case",
"properties",
"initial_conditions",
"velocity_physical"), scales)
15511 if "flow_direction" in ic:
15515 "initial_conditions.flow_direction is required for curvilinear Constant IC "
15516 "when no INLET face exists."
15520 if "flow_direction" in ic:
15522 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
15523 "Use velocity_physical + flow_direction for curvilinear mode."
15526 result[
"ic_coordinate_system"] = cs_code
15528 ic_path = (
"case",
"properties",
"initial_conditions")
15529 result[
"ucont_x"] =
to_solver_units(u, ic_path + (
"u_physical",), scales)
15530 result[
"ucont_y"] =
to_solver_units(v, ic_path + (
"v_physical",), scales)
15531 result[
"ucont_z"] =
to_solver_units(w, ic_path + (
"w_physical",), scales)
15533 elif finit_code == 2:
15534 if any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical")):
15536 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
15538 if "velocity_physical" in ic:
15540 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
15542 if "peak_velocity_physical" not in ic:
15543 raise KeyError(
"peak_velocity_physical")
15545 peak = float(ic[
"peak_velocity_physical"])
15546 except (TypeError, ValueError)
as exc:
15548 f
"Invalid value for initial_conditions.peak_velocity_physical: "
15549 f
"{ic['peak_velocity_physical']!r}. Expected a numeric value."
15552 peak, (
"case",
"properties",
"initial_conditions",
"peak_velocity_physical"), scales)
15554 if "flow_direction" in ic:
15559 fd_axis_name = {0:
"x", 1:
"y", 2:
"z"}.get(fd_int // 2,
"?")
15560 if inlet_axis
and fd_axis_name != inlet_axis:
15561 token = ic[
"flow_direction"]
15563 f
"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
15564 f
"does not match INLET face axis '{inlet_axis}'."
15566 result[
"flow_direction"] = fd_int
15569 "initial_conditions.flow_direction is required for Poiseuille IC "
15570 "when no INLET face exists."
15577 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
15578 @param[in] value Human-readable or enum-like Eulerian field source.
15579 @return Canonical string accepted by `-euler_field_source`.
15580 @throws ValueError if the input cannot be mapped.
15583 raise ValueError(
"eulerian_field_source cannot be None")
15585 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
15589 "analytical":
"analytical",
15591 mapped = aliases.get(normalized)
15594 f
"Unknown operation_mode.eulerian_field_source '{value}'. "
15595 "Use one of: 'solve', 'load', 'analytical'."
15602 @brief Whether the configured initial condition is the field the solver starts from.
15603 @details A restart (start_step > 0) starts from saved state, and the load and
15604 analytical Eulerian sources supply their own field, so in each of those the
15605 configured initial condition is never read.
15606 @param[in] case_cfg Parsed case configuration.
15607 @param[in] solver_cfg Parsed solver configuration.
15608 @return True only for a fresh solve.
15611 ((solver_cfg
or {}).get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
15613 start_step = int(((case_cfg
or {}).get(
"run_control", {})
or {}).get(
"start_step", 0)
or 0)
15614 return source ==
"solve" and start_step == 0
15619 @brief Asset kinds a run with these configurations would build but never read.
15620 @param[in] case_cfg Parsed case configuration.
15621 @param[in] solver_cfg Parsed solver configuration.
15622 @return Kinds to leave out of the run's asset materialization.
15629 @brief Normalizes the analytical solution selector to the C-side canonical string.
15630 @param[in] value Human-readable analytical solution selector.
15631 @return Canonical string accepted by `-analytical_type`.
15632 @throws ValueError if the input cannot be mapped.
15636 raise ValueError(
"analytical_type cannot be None")
15638 normalized = str(value).strip().upper().replace(
"-",
"_").replace(
" ",
"_")
15639 if normalized
not in ANALYTICAL_SOLUTION_TYPES:
15641 f
"Unknown operation_mode.analytical_type '{value}'. "
15642 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
15648 @brief Parse initial-condition velocity components with mode-aware defaults.
15649 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
15650 @param[in] finit_code Normalized `-finit` integer code.
15651 @param[in] require_explicit If True, all three component keys must be present.
15652 @return Tuple `(u, v, w)` in physical units.
15653 @throws KeyError if a required component key is missing.
15654 @throws ValueError if a component cannot be converted to float.
15656 component_keys = (
"u_physical",
"v_physical",
"w_physical")
15658 for key
in component_keys:
15659 if key
not in initial_conditions:
15660 if require_explicit:
15661 raise KeyError(key)
15664 raw_value = initial_conditions[key]
15666 components.append(float(raw_value))
15667 except (TypeError, ValueError)
as exc:
15669 f
"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
15671 return tuple(components)
15675 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
15676 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
15677 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
15678 @throws ValueError if different blocks imply different inlet axes.
15680 face_order = (
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta")
15682 "-Xi":
"x",
"+Xi":
"x",
15683 "-Eta":
"y",
"+Eta":
"y",
15684 "-Zeta":
"z",
"+Zeta":
"z",
15688 for block_bcs
in prepared_blocks:
15689 face_map = {entry[
"face"]: entry
for entry
in block_bcs}
15690 for face
in face_order:
15691 entry = face_map.get(face)
15692 if entry
and entry[
"type"] ==
"INLET":
15693 inlet_axes.add(face_axis[face])
15698 if len(inlet_axes) != 1:
15700 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
15701 f
"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
15703 return next(iter(inlet_axes))
15707 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
15708 @param[in] value Canonical particle initialization mode.
15709 @return Canonical integer code accepted by -pinit.
15710 @throws ValueError if the input cannot be mapped.
15714 raise ValueError(
"particle init mode cannot be None")
15716 name = str(value).strip()
15718 if name ==
"SurfaceEdges":
15720 "particle init_mode 'SurfaceEdges' is retired: use 'Surface' for inlet seeding, "
15721 "or 'PointSource' to place particles at a chosen point."
15730 f
"Unknown particle init_mode '{value}'. Use one of: "
15731 "'Surface', 'Volume', 'PointSource'."
15737 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
15738 @param[in] value Canonical interpolation method name.
15739 @return Integer code accepted by -interpolation_method.
15740 @throws ValueError if the input cannot be mapped.
15743 raise ValueError(
"interpolation method cannot be None")
15747 "CornerAveraged": 1,
15749 }.get(str(value).strip())
15752 f
"Unknown interpolation_method '{value}'. Use one of: "
15753 "'Trilinear', 'CornerAveraged', 'FaceFlux'."
15758LES_MODEL_NAMES = {1:
"constant_smagorinsky", 2:
"dynamic_smagorinsky", 3:
"vreman", 4:
"wale"}
15761LES_MODEL_OWNED_KEYS = {
15762 1: (
"constant_cs",),
15763 2: (
"dynamic_frequency",
"test_filter",
"averaging",
"clipping"),
15764 3: (
"vreman_coefficient",),
15765 4: (
"wale_coefficient",),
15769LES_MODEL_KEY_OWNER_DESCRIPTIONS = {
15770 1:
"the constant Smagorinsky coefficient",
15771 2:
"the dynamic procedure",
15772 3:
"the Vreman model",
15773 4:
"the WALE model",
15779 @brief Maps LES model selectors to C enum/int codes (-les).
15780 @param[in] value LES selector name or legacy integer/bool value.
15781 @return Integer code accepted by -les.
15782 @throws ValueError if the input cannot be mapped.
15784 if isinstance(value, bool):
15785 return 1
if value
else 0
15786 if isinstance(value, int):
15787 if value
in (0, 1, 2, 3, 4):
15789 raise ValueError(
"models.physics.turbulence.les must be 0-4, false/true, or a supported model block.")
15791 raise ValueError(
"LES model cannot be None")
15793 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
15800 "constant_smagorinsky": 1,
15803 "dynamic_smagorinsky": 2,
15809 f
"Unknown LES model '{value}'. Use one of: 'none', "
15810 "'constant_smagorinsky', 'dynamic_smagorinsky', 'vreman', 'wale'."
15816 @brief Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
15817 @param[in] value Test-filter selector name or integer code.
15818 @return 0 for the volume-weighted box filter, 1 for the i/k Simpson filter.
15819 @throws ValueError if the input cannot be mapped.
15821 if isinstance(value, bool):
15822 raise ValueError(
"models.physics.turbulence.les.test_filter.kernel must name a filter, not a boolean.")
15823 if isinstance(value, int):
15824 if value
in (0, 1):
15826 raise ValueError(
"models.physics.turbulence.les.test_filter.kernel must be 0, 1, or a supported filter name.")
15828 raise ValueError(
"LES test_filter.kernel cannot be None")
15830 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
15832 "volume_weighted_box": 0,
15838 f
"Unknown LES test filter kernel '{value}'. Use one of: "
15839 "'volume_weighted_box', 'simpson_ik'."
15845 @brief Maps LES grid-filter-width model names to the C -les_filter_width flag.
15846 @param[in] value Filter-width model name or integer code.
15847 @return 0 for cube-root volume, 1 for the geometric mean of the cell extents,
15848 2 for the longest cell extent, 3 for the Scotti aspect-ratio correction.
15849 @throws ValueError if the input cannot be mapped.
15851 if isinstance(value, bool):
15852 raise ValueError(
"models.physics.turbulence.les.filter_width must name a model, not a boolean.")
15853 if isinstance(value, int):
15854 if value
in (0, 1, 2, 3):
15856 raise ValueError(
"models.physics.turbulence.les.filter_width must be 0, 1, 2, 3, or a supported model name.")
15858 raise ValueError(
"LES filter_width cannot be None")
15860 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
15862 "cube_root_volume": 0,
15863 "geometric_mean": 1,
15869 f
"Unknown LES filter width model '{value}'. Use one of: "
15870 "'cube_root_volume', 'geometric_mean', 'max_edge', 'scotti'."
15876 @brief Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
15877 @param[in] value Averaging mode name or integer code.
15878 @return 0 for pointwise local averaging, 1 for homogeneous directions, 2 for the
15880 @throws ValueError if the input cannot be mapped.
15882 if isinstance(value, bool):
15883 raise ValueError(
"models.physics.turbulence.les.averaging.mode must name a mode, not a boolean.")
15884 if isinstance(value, int):
15885 if value
in (0, 1, 2):
15887 raise ValueError(
"models.physics.turbulence.les.averaging.mode must be 0, 1, 2, or a supported mode name.")
15889 raise ValueError(
"LES averaging.mode cannot be None")
15891 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
15899 f
"Unknown LES averaging mode '{value}'. Use one of: "
15900 "'local', 'homogeneous', 'global'."
15906 @brief Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
15907 @param[in] value Clipping mode name or integer code.
15908 @return 0 to clamp into [0, max_cs^2], 1 to discard negatives only, 2 to keep the
15909 signed coefficient so backscatter survives.
15910 @throws ValueError if the input cannot be mapped.
15912 if isinstance(value, bool):
15913 raise ValueError(
"models.physics.turbulence.les.clipping.mode must name a mode, not a boolean.")
15914 if isinstance(value, int):
15915 if value
in (0, 1, 2):
15917 raise ValueError(
"models.physics.turbulence.les.clipping.mode must be 0, 1, 2, or a supported mode name.")
15919 raise ValueError(
"LES clipping.mode cannot be None")
15921 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
15924 "clip_negative": 1,
15929 f
"Unknown LES clipping mode '{value}'. Use one of: "
15930 "'clamp', 'clip_negative', 'none'."
15935LES_AVERAGING_DIRECTION_AXES = (
"i",
"j",
"k")
15939 @brief Maps a list of homogeneous logical directions to the C flag's string form.
15940 @param[in] value List or string naming a subset of the i, j, and k directions.
15941 @return The selected directions as a canonically ordered subset of "ijk".
15942 @throws ValueError if a direction is unknown or repeated.
15946 if isinstance(value, str):
15947 tokens = [token
for token
in value.strip().lower()]
15948 elif isinstance(value, (list, tuple)):
15949 tokens = [str(token).strip().lower()
for token
in value]
15952 "models.physics.turbulence.les.averaging.directions must be a list such as [i, k]."
15956 for token
in tokens:
15957 if token
not in LES_AVERAGING_DIRECTION_AXES:
15959 f
"Unknown LES averaging direction '{token}'. Use a subset of ['i', 'j', 'k']."
15961 if token
in selected:
15963 f
"LES averaging direction '{token}' is repeated; list each direction once."
15965 selected.append(token)
15966 return "".join(axis
for axis
in LES_AVERAGING_DIRECTION_AXES
if axis
in selected)
15970 @brief Maps wall-function model selectors to the C -wallfunction flag.
15971 @param[in] value Wall-function selector name, or None for the default.
15972 @return Integer code accepted by -wallfunction; 1 log law, 2 Werner-Wengle, 3 Cabot.
15973 @throws ValueError if the input cannot be mapped.
15977 if isinstance(value, bool):
15978 raise ValueError(
"models.physics.turbulence.wall_function.model must name a model, not a boolean.")
15979 if isinstance(value, int):
15980 if value
in (1, 2, 3):
15982 raise ValueError(
"models.physics.turbulence.wall_function.model must be 1, 2, 3, or a supported model name.")
15983 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
15988 "werner_wengle": 2,
15993 "Unknown wall_function model '%s'. Use one of: 'log_law', 'werner', 'cabot'." % value)
15998 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
15999 @param[in] cfg Mapping that may contain `enabled`.
16000 @param[in] path Human-readable config path for diagnostics.
16001 @param[in] default Value used when `enabled` is omitted.
16002 @return Boolean enabled state.
16003 @throws ValueError if `enabled` is not a YAML boolean.
16005 if 'enabled' not in cfg:
16007 if not isinstance(cfg[
'enabled'], bool):
16008 raise ValueError(f
"{path}.enabled must be true or false.")
16009 return cfg[
'enabled']
16013 @brief Appends the LES closure parameter flags from a structured les block.
16014 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
16015 @param[out] control_lines A list of strings to which C-flags will be appended.
16016 @throws ValueError if a selector name or nested block shape is unsupported.
16018 if 'constant_cs' in les_cfg:
16019 control_lines.append(f
"-les_constant_cs {format_flag_value(les_cfg['constant_cs'])}")
16020 if 'vreman_coefficient' in les_cfg:
16021 control_lines.append(f
"-les_vreman_coefficient {format_flag_value(les_cfg['vreman_coefficient'])}")
16022 if 'wale_coefficient' in les_cfg:
16023 control_lines.append(f
"-les_wale_coefficient {format_flag_value(les_cfg['wale_coefficient'])}")
16024 if 'dynamic_frequency' in les_cfg:
16025 control_lines.append(f
"-les_dynamic_frequency {format_flag_value(les_cfg['dynamic_frequency'])}")
16026 if 'filter_width' in les_cfg:
16027 control_lines.append(f
"-les_filter_width {normalize_les_filter_width(les_cfg['filter_width'])}")
16031 test_filter = les_cfg.get(
'test_filter')
16032 if test_filter
is not None:
16033 if not isinstance(test_filter, dict):
16034 test_filter = {
'kernel': test_filter}
16035 if 'kernel' in test_filter:
16036 control_lines.append(
16037 f
"-les_test_filter_kernel {normalize_les_test_filter(test_filter['kernel'])}")
16038 if 'width_ratio' in test_filter:
16039 control_lines.append(
16040 f
"-les_test_filter_width_ratio {format_flag_value(test_filter['width_ratio'])}")
16042 averaging = les_cfg.get(
'averaging')
16043 if averaging
is not None:
16044 if not isinstance(averaging, dict):
16045 averaging = {
'mode': averaging}
16046 if 'mode' in averaging:
16047 control_lines.append(
16048 f
"-les_averaging_mode {normalize_les_averaging_mode(averaging['mode'])}")
16049 if 'directions' in averaging:
16052 control_lines.append(f
"-les_averaging_directions {directions}")
16054 clipping = les_cfg.get(
'clipping')
16055 if clipping
is not None:
16056 if not isinstance(clipping, dict):
16057 clipping = {
'mode': clipping}
16058 if 'mode' in clipping:
16059 control_lines.append(f
"-les_clip_mode {normalize_les_clip_mode(clipping['mode'])}")
16060 if 'max_cs' in clipping:
16061 control_lines.append(f
"-les_clip_max_cs {format_flag_value(clipping['max_cs'])}")
16062 if 'min_viscosity_ratio' in clipping:
16063 control_lines.append(
16064 f
"-les_min_viscosity_ratio {format_flag_value(clipping['min_viscosity_ratio'])}")
16066 gradient_model = les_cfg.get(
'gradient_model')
16067 if gradient_model
is not None:
16068 if not isinstance(gradient_model, dict):
16069 gradient_model = {
'enabled': gradient_model}
16071 "models.physics.turbulence.les.gradient_model")
16072 control_lines.append(f
"-les_gradient_model {1 if enabled else 0}")
16076 @brief Resolve the LES diagnostic log settings from monitor.yml or their deprecated location.
16077 @details The log is observation, not physics, so it is configured in monitor.yml
16078 `diagnostics.les`; case.yml `models.physics.turbulence.les.diagnostics` is read as a
16079 deprecated alias. Setting both is refused. `yoshizawa_ci` only scales the subgrid
16080 kinetic energy the log reports, so it belongs with the log.
16081 @param[in] case_cfg Parsed case configuration.
16082 @param[in] monitor_cfg Parsed monitor configuration.
16083 @param[in] warn Print a deprecation warning when the case.yml location is used.
16084 @return {"enabled", optional "cadence", optional "yoshizawa_ci"}, or None when unset.
16085 @throws ValueError for a duplicated, malformed, or out-of-range setting.
16087 monitor_value = (((monitor_cfg
or {}).get(
"diagnostics")
or {}).get(
"les")
16088 if isinstance((monitor_cfg
or {}).get(
"diagnostics"), dict)
else None)
16089 les_cfg = ((((case_cfg
or {}).get(
"models")
or {}).get(
"physics")
or {}).get(
"turbulence")
or {}).get(
"les")
16090 case_value = les_cfg.get(
"diagnostics")
if isinstance(les_cfg, dict)
else None
16091 if monitor_value
is not None and case_value
is not None:
16092 raise ValueError(
"LES diagnostics are set in monitor.yml (diagnostics.les) and in their deprecated "
16093 "case.yml location (models.physics.turbulence.les.diagnostics); keep only monitor.yml.")
16094 value, label = ((monitor_value,
"monitor.yml diagnostics.les")
if monitor_value
is not None
16095 else (case_value,
"case.yml models.physics.turbulence.les.diagnostics"))
16098 if not isinstance(value, dict):
16099 value = {
"enabled": value}
16100 unknown = sorted(set(value) - {
"enabled",
"cadence",
"yoshizawa_ci"})
16102 raise ValueError(f
"{label} has unsupported key(s): {unknown}. Use enabled, cadence, yoshizawa_ci.")
16104 if "cadence" in value:
16105 if isinstance(value[
"cadence"], bool)
or not isinstance(value[
"cadence"], int)
or value[
"cadence"] <= 0:
16106 raise ValueError(f
"{label}.cadence must be a positive integer.")
16107 resolved[
"cadence"] = value[
"cadence"]
16108 if "yoshizawa_ci" in value:
16111 except (TypeError, ValueError)
as exc:
16112 raise ValueError(f
"{label}.yoshizawa_ci must be numeric.")
from exc
16114 raise ValueError(f
"{label}.yoshizawa_ci must be non-negative.")
16115 resolved[
"yoshizawa_ci"] = ci
16116 if monitor_value
is None and warn:
16117 print(
"[WARNING] case.yml models.physics.turbulence.les.diagnostics is deprecated; "
16118 "set it in monitor.yml as diagnostics.les.", file=sys.stderr)
16124 @brief Append the LES diagnostic log flags resolved by resolve_les_diagnostics().
16125 @param[in] diagnostics Resolved settings, or None to append nothing.
16126 @param[out] control_lines A list of strings to which C-flags will be appended.
16128 if diagnostics
is None:
16130 control_lines.append(f
"-les_diagnostics {'true' if diagnostics['enabled'] else 'false'}")
16131 if "cadence" in diagnostics:
16132 control_lines.append(f
"-les_diagnostics_cadence {format_flag_value(diagnostics['cadence'])}")
16133 if "yoshizawa_ci" in diagnostics:
16134 control_lines.append(f
"-les_yoshizawa_ci {format_flag_value(diagnostics['yoshizawa_ci'])}")
16138 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
16139 @param[in] models Parsed case.yml `models` mapping.
16140 @param[out] control_lines A list of strings to which C-flags will be appended.
16141 @param[in] scales The case's reference scales. Required only when a physical
16142 roughness height is set.
16143 @throws RuntimeError when a roughness height is set and no scales were supplied.
16145 turbulence_cfg = models.get(
'physics', {}).get(
'turbulence', {})
16146 if not turbulence_cfg:
16148 if not isinstance(turbulence_cfg, dict):
16149 raise ValueError(
"models.physics.turbulence must be a mapping.")
16151 les_cfg = turbulence_cfg.get(
'les')
16152 rans_cfg = turbulence_cfg.get(
'rans')
16153 wall_cfg = turbulence_cfg.get(
'wall_function')
16156 if isinstance(les_cfg, dict):
16158 model_value = les_cfg.get(
'model',
'constant_smagorinsky')
16160 control_lines.append(f
"-les {les_code}")
16162 elif les_cfg
is not None:
16164 control_lines.append(f
"-les {les_code}")
16166 if rans_cfg
is not None:
16167 raise ValueError(RANS_PLANNED_REFUSAL)
16169 if isinstance(wall_cfg, dict):
16174 control_lines.append(f
"-wallfunction {wall_model if enabled else 0}")
16175 if 'roughness_height' in wall_cfg:
16177 raise RuntimeError(
"wall_function.roughness_height is a physical length and needs the case reference scales.")
16179 wall_cfg[
'roughness_height'],
16180 (
"case",
"models",
"physics",
"turbulence",
"wall_function",
"roughness_height"), scales)
16181 control_lines.append(f
"-wall_roughness {format_flag_value(roughness)}")
16182 elif wall_cfg
is not None:
16183 control_lines.append(f
"-wallfunction {format_flag_value(wall_cfg)}")
16187 @brief Guard a value that is written verbatim into the generated control file.
16189 @details PETSc reads the control file line by line, so a value containing a newline
16190 writes additional option lines - which defeats every key-based check by
16191 smuggling a whole new flag. Rejecting the value is the only safe handling;
16192 there is no quoting that makes a multi-line option meaningful.
16193 @param[in] value Configured value destined for a control line.
16194 @param[in] context YAML path, for the error message.
16195 @return The value unchanged when it is safe to emit.
16196 @throws SystemExit when the value would inject an option line.
16199 if any(character
in text
for character
in (
"\n",
"\r")):
16201 f
"[FATAL] {context} contains a newline. Values written to the generated control "
16202 f
"file must occupy a single line; a multi-line value would inject additional "
16212 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
16213 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
16214 is skipped. All other values are emitted as "<flag> <value>".
16215 @param[out] control_lines The destination list of control-file lines.
16216 @param[in] options Mapping of raw CLI flags to values.
16220 for flag, value
in options.items():
16221 if isinstance(flag, str)
and flag.strip()
in RESERVED_DIRECTORY_FLAGS:
16225 f
"Refusing to emit reserved directory flag '{flag.strip()}' from passthrough "
16226 "options; run directories are fixed by the workspace contract."
16228 if isinstance(value, bool):
16230 control_lines.append(str(flag))
16232 control_lines.append(
16233 f
"{flag} {control_value(format_flag_value(value), f'passthrough option {flag}')}"
16237SOLVER_MONITORING_POISSON_FLAG_MAP = {
16238 "pic_true_residual":
"-ps_ksp_pic_monitor_true_residual",
16239 "true_residual":
"-ps_ksp_monitor_true_residual",
16240 "converged_reason":
"-ps_ksp_converged_reason",
16241 "view":
"-ps_ksp_view",
16244SOLVER_MONITORING_MOMENTUM_FLAG_MAP = {
16245 "newton_krylov_history":
"-mom_nk_pic_monitor",
16246 "snes_monitor":
"-mom_nk_snes_monitor",
16247 "snes_converged_reason":
"-mom_nk_snes_converged_reason",
16248 "ksp_monitor":
"-mom_nk_ksp_monitor",
16249 "ksp_converged_reason":
"-mom_nk_ksp_converged_reason",
16255 @brief Resolve human-readable solver monitoring YAML to raw control flags.
16256 @param[in] monitor_cfg Parsed monitor.yml mapping.
16257 @return Mapping of raw C/PETSc flags to values.
16259 solver_mon_cfg = monitor_cfg.get(
"solver_monitoring", {})
if isinstance(monitor_cfg, dict)
else {}
16260 if solver_mon_cfg
is None:
16262 if not isinstance(solver_mon_cfg, dict):
16263 raise ValueError(
"monitor.solver_monitoring must be a mapping when provided.")
16267 momentum_cfg = solver_mon_cfg.get(
"momentum", {})
16268 if momentum_cfg
is None:
16270 if not isinstance(momentum_cfg, dict):
16271 raise ValueError(
"monitor.solver_monitoring.momentum must be a mapping when provided.")
16272 unknown_momentum = sorted(set(momentum_cfg.keys()) - set(SOLVER_MONITORING_MOMENTUM_FLAG_MAP.keys()))
16273 if unknown_momentum:
16274 raise ValueError(f
"monitor.solver_monitoring.momentum has unsupported key(s): {unknown_momentum}.")
16275 for key, flag
in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.items():
16276 if key
in momentum_cfg:
16277 value = momentum_cfg[key]
16278 if not isinstance(value, bool):
16279 raise ValueError(f
"monitor.solver_monitoring.momentum.{key} must be boolean.")
16280 flags[flag] = value
16282 poisson_cfg = solver_mon_cfg.get(
"poisson", {})
16283 if poisson_cfg
is None:
16285 if not isinstance(poisson_cfg, dict):
16286 raise ValueError(
"monitor.solver_monitoring.poisson must be a mapping when provided.")
16287 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
16288 if unknown_poisson:
16289 raise ValueError(f
"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
16290 for key, flag
in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
16291 if key
in poisson_cfg:
16292 value = poisson_cfg[key]
16293 if not isinstance(value, bool):
16294 raise ValueError(f
"monitor.solver_monitoring.poisson.{key} must be boolean.")
16295 flags[flag] = value
16297 passthrough = solver_mon_cfg.get(
"petsc_passthrough_options", {})
16298 if passthrough
is None:
16300 if not isinstance(passthrough, dict):
16301 raise ValueError(
"monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
16302 flags.update(passthrough)
16306 for key, value
in solver_mon_cfg.items()
16307 if isinstance(key, str)
and key.startswith(
"-")
16309 flags.update(legacy_raw)
16311 unknown_top = sorted(
16313 for key
in solver_mon_cfg.keys()
16314 if key
not in {
"momentum",
"poisson",
"petsc_passthrough_options"}
and not (isinstance(key, str)
and key.startswith(
"-"))
16318 "monitor.solver_monitoring has unsupported key(s): "
16319 f
"{unknown_top}. Use 'momentum'/'poisson' for structured monitors or "
16320 "'petsc_passthrough_options' for raw PETSc flags."
16326MAX_TRACED_PARTICLES = 64
16331 @brief Return the sorted, distinct particle IDs of `logging.trace_particles`.
16332 @param[in] monitor_cfg Parsed monitor configuration.
16333 @return Sorted list of non-negative integer IDs; empty when tracing is off.
16334 @throws ValueError When the value is not a list of at most 64 non-negative integers.
16336 logging_cfg = monitor_cfg.get(
'logging')
if isinstance(monitor_cfg, dict)
else None
16337 if not isinstance(logging_cfg, dict)
or logging_cfg.get(
'trace_particles')
is None:
16339 raw = logging_cfg[
'trace_particles']
16340 if not isinstance(raw, list)
or any(isinstance(pid, bool)
or not isinstance(pid, int)
or pid < 0
for pid
in raw):
16341 raise ValueError(
"'logging.trace_particles' must be a list of non-negative integer particle IDs "
16343 traced = sorted(set(raw))
16344 if len(traced) > MAX_TRACED_PARTICLES:
16345 raise ValueError(f
"'logging.trace_particles' accepts at most {MAX_TRACED_PARTICLES} particle IDs "
16346 f
"(got {len(traced)}).")
16352 @brief Return the effective particle-console snapshot cadence from monitor.yml.
16353 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
16354 @return Value returned by `resolve_particle_console_output_frequency()`.
16356 if 'particle_console_output_frequency' in io_cfg:
16357 return io_cfg[
'particle_console_output_frequency']
16358 return io_cfg.get(
'data_output_frequency')
16362 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
16363 @param[in] case_cfg The parsed case.yml configuration dictionary.
16364 @param[out] control_lines A list of strings to which C-flags will be appended.
16366 models = case_cfg.get(
'models', {})
16368 'domain': {
'blocks':
'-nblk'},
16369 'physics.fsi': {
'immersed':
'-imm',
'moving_fsi':
'-fsi'},
16370 'physics.particles': {
'count':
'-numParticles',
'random_seed':
'-particle_random_seed'},
16372 for section_path, flags
in FLAG_MAP.items():
16373 current_level = models
16375 for key
in section_path.split(
'.'): current_level = current_level[key]
16376 for yaml_key, flag
in flags.items():
16377 if yaml_key
in current_level:
16378 control_lines.append(f
"{flag} {format_flag_value(current_level[yaml_key])}")
16379 except KeyError:
continue
16381 wall_cfg = (((models.get(
'physics')
or {}).get(
'turbulence')
or {}).get(
'wall_function'))
16382 needs_scales = isinstance(wall_cfg, dict)
and 'roughness_height' in wall_cfg
16386 if models.get(
'physics', {}).get(
'dimensionality') ==
'2D':
16387 control_lines.append(
"-TwoD 1")
16389 particles_cfg = models.get(
'physics', {}).get(
'particles', {})
16390 p_init_mode_str = particles_cfg.get(
'init_mode',
'Surface')
16392 control_lines.append(f
"-pinit {pinit_code}")
16393 print(f
" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
16395 if pinit_code == 2:
16396 point_cfg = particles_cfg.get(
'point_source', {})
16397 if not isinstance(point_cfg, dict):
16398 raise ValueError(
"models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
16400 point_path = (
"case",
"models",
"physics",
"particles",
"point_source")
16402 psrc = [
to_solver_units(point_cfg[axis], point_path + (axis,), scales)
for axis
in (
"x",
"y",
"z")]
16403 except (KeyError, TypeError, ValueError):
16404 raise ValueError(
"PointSource init_mode requires numeric point_source.{x,y,z} values.")
16405 for axis, value
in zip((
"x",
"y",
"z"), psrc):
16406 control_lines.append(f
"-psrc_{axis} {value}")
16407 print(f
" - Particle Point Source: ({point_cfg['x']}, {point_cfg['y']}, {point_cfg['z']}) "
16408 f
"-> solver units ({psrc[0]}, {psrc[1]}, {psrc[2]})")
16410 if particles_cfg.get(
'fields'):
16412 control_lines.append(f
"-particle_fields_count {len(particle_fields)}")
16413 for index, entry
in enumerate(particle_fields):
16414 control_lines.append(f
"-particle_fields_{index}_name {entry['name']}")
16415 for component, expression
in enumerate(entry[
"expressions"]):
16416 text =
control_value(expression, f
"models.physics.particles.fields.{entry['name']}")
16417 control_lines.append(f
"-particle_fields_{index}_expr_{component} \"{text}\"")
16418 print(f
" - Particle Field Initial Value: {entry['name']} = {' ; '.join(entry['expressions'])}")
16420 p_restart_mode = particles_cfg.get(
'restart_mode')
16422 p_restart_mode_normalized = str(p_restart_mode).lower()
16423 if p_restart_mode_normalized
not in PARTICLE_RESTART_MODES:
16424 raise ValueError(f
"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
16425 control_lines.append(f
"-particle_restart_mode \"{p_restart_mode}\"")
16430KRYLOV_KSP_TYPES = {
16431 "gmres",
"fgmres",
"lgmres",
"dgmres",
"pgmres",
"gcr",
16432 "cg",
"cgne",
"cgs",
"bcgs",
"ibcgs",
"fbcgs",
"fbcgsr",
"bcgsl",
16433 "tfqmr",
"tcqmr",
"minres",
"symmlq",
"cr",
"lsqr",
"pipecg",
"pipefgmres",
16438 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
16439 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
16440 @param[in] scales The case's reference scales. Required only when solver.yml sets a
16441 dimensional value, which is physical and is converted here.
16442 @param[in] case_cfg The parsed case.yml, which holds the scalar-transport parameters.
16443 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
16447 def solver_value(path: tuple, value) -> float:
16449 @brief Convert one solver.yml value by its recorded dimension.
16450 @param[in] path Schema path under solver.yml.
16451 @param[in] value Value as written.
16452 @return The value in solver units.
16453 @throws RuntimeError when the value is dimensional and no scales were supplied; that
16454 is a caller error, kept distinct from the ValueError of an invalid value.
16456 full_path = (
"solver",) + path
16460 raise RuntimeError(f
"{'.'.join(path)} is a physical value and needs the case reference scales.")
16462 if 'operation_mode' in solver_cfg
and isinstance(solver_cfg[
'operation_mode'], dict):
16463 op_mode = solver_cfg[
'operation_mode']
16464 if 'eulerian_field_source' in op_mode:
16466 flags[
'-euler_field_source'] = f
"\"{normalized_source}\""
16467 if 'analytical_type' in op_mode
and op_mode.get(
'analytical_type')
is not None:
16469 flags[
'-analytical_type'] = f
"\"{normalized_analytical_type}\""
16470 if normalized_analytical_type ==
"UNIFORM_FLOW":
16471 uniform_flow_cfg = op_mode.get(
'uniform_flow', {})
16472 if not isinstance(uniform_flow_cfg, dict):
16473 raise ValueError(
"operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
16475 for component
in (
"u",
"v",
"w"):
16476 flags[f
'-analytical_uniform_{component}'] = solver_value(
16477 (
"operation_mode",
"uniform_flow", component), uniform_flow_cfg[component])
16478 except KeyError
as exc:
16479 raise ValueError(f
"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.")
from exc
16480 except (TypeError, ValueError)
as exc:
16481 raise ValueError(
"operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.")
from exc
16483 verification_cfg = solver_cfg.get(
'verification', {})
16484 if verification_cfg:
16485 if not isinstance(verification_cfg, dict):
16486 raise ValueError(
"verification must be a mapping when provided.")
16487 sources_cfg = verification_cfg.get(
'sources', {})
16488 if not isinstance(sources_cfg, dict):
16489 raise ValueError(
"verification.sources must be a mapping when provided.")
16490 diff_cfg = sources_cfg.get(
'diffusivity')
16491 if diff_cfg
is not None:
16492 if not isinstance(diff_cfg, dict):
16493 raise ValueError(
"verification.sources.diffusivity must be a mapping.")
16495 flags[
'-verification_diffusivity_mode'] = f
"\"{str(diff_cfg['mode']).strip().lower()}\""
16496 flags[
'-verification_diffusivity_profile'] = f
"\"{str(diff_cfg['profile']).strip().upper()}\""
16497 for key
in (
"gamma0",
"slope_x"):
16498 flags[f
'-verification_diffusivity_{key}'] = solver_value(
16499 (
"verification",
"sources",
"diffusivity", key), diff_cfg[key])
16500 except KeyError
as exc:
16501 raise ValueError(f
"verification.sources.diffusivity.{exc.args[0]} is required.")
from exc
16502 except (TypeError, ValueError)
as exc:
16503 raise ValueError(
"verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.")
from exc
16505 scalar_cfg = sources_cfg.get(
'scalar')
16506 if scalar_cfg
is not None:
16507 if not isinstance(scalar_cfg, dict):
16508 raise ValueError(
"verification.sources.scalar must be a mapping.")
16510 flags[
'-verification_scalar_mode'] = f
"\"{str(scalar_cfg['mode']).strip().lower()}\""
16511 flags[
'-verification_scalar_profile'] = f
"\"{str(scalar_cfg['profile']).strip().upper()}\""
16512 except KeyError
as exc:
16513 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
16515 scalar_numeric_keys = {
16516 'CONSTANT': (
'value',),
16517 'LINEAR_X': (
'phi0',
'slope_x'),
16518 'SIN_PRODUCT': (
'amplitude',
'kx',
'ky',
'kz'),
16520 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
16521 for key
in scalar_numeric_keys.get(profile, ()):
16523 flags[f
'-verification_scalar_{key}'] = solver_value(
16524 (
"verification",
"sources",
"scalar", key), scalar_cfg[key])
16525 except KeyError
as exc:
16526 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
16527 except (TypeError, ValueError)
as exc:
16528 raise ValueError(f
"verification.sources.scalar.{key} must be numeric.")
from exc
16531 flags[SCALAR_TRANSPORT_FLAGS[key]] = value
16533 selected_solver =
None
16534 if 'strategy' in solver_cfg:
16535 s = solver_cfg[
'strategy']
16536 if 'central_diff' in s:
16539 if 'momentum_solver' in s:
16541 elif 'implicit' in s:
16542 raise ValueError(
"Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
16544 def _warn_inactive_absolute_tol(cfg: dict, where: str):
16546 @brief Warn when absolute_tol is set but cannot affect convergence.
16548 absolute_tol bounds the velocity update. Since |dU| ~ dtau*|R|, bounding it
16549 absolutely is a disguised, step-size-dependent residual bound, so it takes no
16550 part once a residual tolerance is active -- which is now the default. Setting it
16551 therefore has no effect, and silently ineffective knobs are what this warning
16552 exists to prevent. See docs/pages/24_Dual_Time_Picard_Jameson_RK.md.
16553 @param[in] cfg Tolerance mapping to inspect for `absolute_tol` and the residual keys.
16554 @param[in] where Config path used to locate the offending key in the warning text.
16555 @return None; emits a warning on stderr when `absolute_tol` cannot take effect.
16557 if 'absolute_tol' not in cfg:
16563 @brief Report whether a residual tolerance is explicitly disabled.
16564 @param[in] key Residual tolerance key to inspect.
16565 @return True when the key is present and non-positive.
16567 v = cfg.get(key,
None)
16569 return v
is not None and float(v) <= 0.0
16570 except (TypeError, ValueError):
16572 if _off(
'residual_absolute_tol')
and _off(
'residual_relative_tol'):
16575 f
"[WARNING] {where}.absolute_tol is set but takes no part in convergence while a "
16576 "residual tolerance is active (the default). It is retained only for the legacy "
16577 "update-only branch, which can converge falsely when dtau collapses. To control "
16578 "accuracy use residual_relative_tol / residual_absolute_tol. See "
16579 "docs/pages/24_Dual_Time_Picard_Jameson_RK.md.",
16583 ms = solver_cfg.get(
'momentum_solver', {})
16584 if selected_solver
is None:
16585 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
16586 flags[
'-mom_solver_type'] = f
"\"{selected_solver}\""
16588 if 'tolerances' in solver_cfg:
16589 t = solver_cfg[
'tolerances']
16591 'max_iterations':
'-mom_max_pseudo_steps',
16592 'absolute_tol':
'-mom_atol',
16593 'relative_tol':
'-mom_rtol',
16594 'residual_absolute_tol':
'-mom_resid_atol',
16595 'residual_relative_tol':
'-mom_resid_rtol',
16596 'step_tol':
'-imp_stol'
16598 for key, flag
in tol_map.items():
16600 flags[flag] = t[key]
16601 _warn_inactive_absolute_tol(t,
"tolerances")
16603 def _append_dualtime_options(cfg: dict):
16605 @brief Append dualtime options.
16606 @param[in] cfg Argument passed to `_append_dualtime_options()`.
16608 if 'max_pseudo_steps' in cfg:
16609 flags[
'-mom_max_pseudo_steps'] = cfg[
'max_pseudo_steps']
16610 if 'absolute_tol' in cfg:
16611 flags[
'-mom_atol'] = cfg[
'absolute_tol']
16612 _warn_inactive_absolute_tol(
16613 {**solver_cfg.get(
'tolerances', {}), **cfg},
16614 "momentum_solver.dual_time_picard_jameson_rk")
16615 if 'relative_tol' in cfg:
16616 flags[
'-mom_rtol'] = cfg[
'relative_tol']
16617 if 'step_tol' in cfg:
16618 flags[
'-imp_stol'] = cfg[
'step_tol']
16619 if 'pseudo_cfl' in cfg:
16620 pcfl = cfg[
'pseudo_cfl']
16621 if 'initial' in pcfl:
16622 flags[
'-pseudo_cfl'] = pcfl[
'initial']
16623 if 'minimum' in pcfl:
16624 flags[
'-min_pseudo_cfl'] = pcfl[
'minimum']
16625 if 'maximum' in pcfl:
16626 flags[
'-max_pseudo_cfl'] = pcfl[
'maximum']
16627 if 'growth_factor' in pcfl:
16628 flags[
'-pseudo_cfl_growth_factor'] = pcfl[
'growth_factor']
16629 if 'reduction_factor' in pcfl:
16630 flags[
'-pseudo_cfl_reduction_factor'] = pcfl[
'reduction_factor']
16631 if 'jameson_residual_noise_allowance_factor' in cfg:
16632 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'jameson_residual_noise_allowance_factor']
16633 elif 'rk4_residual_noise_allowance_factor' in cfg:
16634 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'rk4_residual_noise_allowance_factor']
16635 if 'ratio_ema_alpha' in cfg:
16636 flags[
'-mom_ratio_ema_alpha'] = cfg[
'ratio_ema_alpha']
16638 def _append_newton_krylov_options(cfg: dict):
16640 @brief Append validated structured Newton--Krylov PETSc options.
16641 @param[in] cfg Structured Newton--Krylov mapping.
16644 jacobian = cfg[
"jacobian"]
16645 flags[
"-mom_nk_jacobian_type"] = jacobian[
"type"]
16646 flags[
"-mom_nk_jacobian_fd_mode"] = jacobian[
"finite_difference"][
"mode"]
16647 preconditioner = cfg[
"preconditioner"]
16648 flags[
"-mom_nk_preconditioner_model"] = preconditioner[
"model"]
16649 flags[
"-mom_nk_preconditioner_structure"] = preconditioner[
"structure"][
"type"]
16650 nonlinear = cfg[
"nonlinear_solver"]
16652 "method":
"-mom_nk_snes_type",
16653 "absolute_tolerance":
"-mom_nk_snes_atol",
16654 "relative_tolerance":
"-mom_nk_snes_rtol",
16655 "step_tolerance":
"-mom_nk_snes_stol",
16656 "max_iterations":
"-mom_nk_snes_max_it",
16658 for key, flag
in nonlinear_map.items():
16659 if key
in nonlinear:
16660 flags[flag] = nonlinear[key]
16661 line_search = nonlinear.get(
"line_search", {})
16662 if "type" in line_search:
16663 flags[
"-mom_nk_snes_linesearch_type"] = line_search[
"type"]
16664 ew = nonlinear.get(
"eisenstat_walker")
16666 flags[
"-mom_nk_snes_ksp_ew"] = ew[
"enabled"]
16669 "version":
"-mom_nk_snes_ksp_ew_version",
16670 "initial_relative_tolerance":
"-mom_nk_snes_ksp_ew_rtol0",
16671 "maximum_relative_tolerance":
"-mom_nk_snes_ksp_ew_rtolmax",
16672 "gamma":
"-mom_nk_snes_ksp_ew_gamma",
16673 "exponent":
"-mom_nk_snes_ksp_ew_alpha",
16674 "safeguard_exponent":
"-mom_nk_snes_ksp_ew_alpha2",
16675 "safeguard_threshold":
"-mom_nk_snes_ksp_ew_threshold",
16677 for key, flag
in ew_map.items():
16679 flags[flag] = ew[key]
16681 linear = cfg[
"linear_solver"]
16683 "method":
"-mom_nk_ksp_type",
16684 "absolute_tolerance":
"-mom_nk_ksp_atol",
16685 "relative_tolerance":
"-mom_nk_ksp_rtol",
16686 "max_iterations":
"-mom_nk_ksp_max_it",
16688 for key, flag
in linear_map.items():
16690 flags[flag] = linear[key]
16691 gmres = linear.get(
"gmres", {})
16692 if "restart" in gmres:
16693 flags[
"-mom_nk_ksp_gmres_restart"] = gmres[
"restart"]
16695 if isinstance(ms, dict):
16696 allowed_ms_keys = {
'type',
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4',
'newton_krylov'}
16697 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
16698 if unknown_ms_keys:
16700 f
"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
16701 "Currently supported blocks: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
16704 if 'dual_time_picard_jameson_rk' in ms
and 'dual_time_picard_rk4' in ms:
16706 "Use only momentum_solver.dual_time_picard_jameson_rk; "
16707 "do not also set its deprecated dual_time_picard_rk4 alias."
16709 dt_picard_cfg = ms.get(
'dual_time_picard_jameson_rk', ms.get(
'dual_time_picard_rk4'))
16710 if dt_picard_cfg
is not None:
16711 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK":
16713 f
"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
16715 if not isinstance(dt_picard_cfg, dict):
16716 raise ValueError(
"momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
16717 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
16718 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
16720 "Use only jameson_residual_noise_allowance_factor; "
16721 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
16723 _append_dualtime_options(dt_picard_cfg)
16724 newton_cfg = ms.get(
'newton_krylov')
16725 if newton_cfg
is not None:
16726 if selected_solver !=
"newton_krylov":
16728 f
"momentum_solver.newton_krylov is set but selected solver is {selected_solver}."
16730 _append_newton_krylov_options(newton_cfg)
16731 def _normalize_poisson_method(value) -> str:
16733 @brief Normalize a user-facing Poisson linear-solver method name.
16734 @param[in] value Method value from the solver YAML.
16735 @return Lowercase PETSc KSP method token.
16737 method = str(value).strip().lower()
16739 raise ValueError(
"poisson_solver.method cannot be empty.")
16740 if method
in POISSON_KSP_REFUSED:
16742 f
"poisson_solver.method '{method}' {POISSON_KSP_REFUSED[method]}: PICurv's "
16743 "multigrid preconditioner is not a single fixed linear operator, and with it "
16744 f
"'{method}' reports convergence while the true residual stalls, so the "
16745 "projection uses the wrong pressure. Use 'fgmres' (default) or 'cg'."
16747 if method
not in POISSON_KSP_METHODS:
16749 f
"poisson_solver.method '{method}' is not one of {sorted(POISSON_KSP_METHODS)}, "
16750 "the methods verified with the multigrid preconditioner. Another PETSc KSP type "
16751 "can still be set through petsc_passthrough_options (-ps_ksp_type), unverified."
16755 def _normalize_poisson_preconditioner(value) -> str:
16757 @brief Normalize and validate the outer Poisson preconditioner name.
16758 @param[in] value Preconditioner value from the solver YAML.
16759 @return PETSc PC token for the supported outer preconditioner.
16761 pc = str(value).strip().lower()
16762 pc = POISSON_PRECONDITIONER_SPELLINGS.get(pc, pc)
16763 if pc
not in POISSON_PRECONDITIONER_TYPES:
16765 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
16766 "The runtime Poisson solver still assumes PETSc PCMG setup."
16770 def _warn_if_krylov_coarse_solver(ksp_type, source_key: str):
16772 @brief Warn when the coarsest multigrid level is given a Krylov solver.
16773 @param[in] ksp_type PETSc KSP token configured for `level_0`.
16774 @param[in] source_key Name of the source YAML block, used in the message.
16775 @details level_0 is the coarse solve at the base of the V-cycle, not a
16776 smoother. A Krylov method there makes the multigrid
16777 preconditioner a nonlinear operator, which decouples the outer
16778 KSP's tracked residual from the true residual b-Ax. This stays a
16779 warning rather than an error because it remains legitimate at
16780 large scale when tolerances are set against the true residual.
16782 if str(ksp_type).strip().lower()
not in KRYLOV_KSP_TYPES:
16785 f
"[WARNING] {source_key}.multigrid.level_solvers.level_0.method = '{ksp_type}' "
16786 "is a Krylov method. level_0 is the multigrid COARSE SOLVE, not a smoother, "
16787 "so a Krylov method there makes the preconditioner nonlinear and the outer "
16788 "KSP's tracked residual can stop matching the true residual b-Ax. "
16789 "Prefer {method: preonly, preconditioner: redundant}. If this is deliberate, "
16790 "enable solver_monitoring.poisson.pic_true_residual and set tolerances against "
16791 "the true residual. See docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.",
16795 def _poisson_level_number(level_name) -> int:
16797 @brief Extract the numeric suffix from a `level_N` multigrid level key.
16798 @param[in] level_name YAML level key supplied by the user.
16799 @return Numeric level suffix.
16801 text = str(level_name).strip()
16802 match = re.fullmatch(
r"level_(\d+)", text)
16804 raise ValueError(f
"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
16805 return int(match.group(1))
16807 def _append_poisson_solver_flags(ps: dict, source_key: str):
16809 @brief Append structured Poisson solver options to the flat PETSc flag map.
16810 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
16811 @param[in] source_key Name of the source YAML block, used in error messages.
16813 if not isinstance(ps, dict):
16814 raise ValueError(f
"{source_key} must be a mapping when provided.")
16818 method = _normalize_poisson_method(ps[
'method'])
16819 flags[
'-ps_ksp_type'] = method
16820 flags.update(POISSON_KSP_METHODS[method])
16821 if 'absolute_tolerance' in ps:
16822 flags[
'-ps_ksp_atol'] = ps[
'absolute_tolerance']
16823 if 'relative_tolerance' in ps:
16824 flags[
'-ps_ksp_rtol'] = ps[
'relative_tolerance']
16825 if 'max_iterations' in ps:
16826 flags[
'-ps_ksp_max_it'] = ps[
'max_iterations']
16827 if 'tolerance' in ps:
16829 f
"{source_key}.tolerance is not a solver control: the runtime read it and never "
16830 "used it. Use absolute_tolerance (-> -ps_ksp_atol) and relative_tolerance "
16831 "(-> -ps_ksp_rtol), which the Poisson KSP applies."
16834 gmres_cfg = ps.get(
'gmres', {})
16835 if gmres_cfg
is not None:
16836 if not isinstance(gmres_cfg, dict):
16837 raise ValueError(f
"{source_key}.gmres must be a mapping when provided.")
16838 if 'restart' in gmres_cfg:
16840 method = _normalize_poisson_method(ps.get(
'method',
'fgmres'))
16841 flags.setdefault(
'-ps_ksp_type', method)
16842 if method
not in GMRES_RESTART_METHODS:
16844 f
"{source_key}.gmres.restart is valid only when {source_key}.method "
16845 "is one of 'gmres', 'fgmres', or 'lgmres'."
16847 flags[
'-ps_ksp_gmres_restart'] = gmres_cfg[
'restart']
16849 preconditioner_cfg = ps.get(
'preconditioner', {})
16850 if preconditioner_cfg:
16851 if not isinstance(preconditioner_cfg, dict):
16852 raise ValueError(f
"{source_key}.preconditioner must be a mapping when provided.")
16853 if 'type' in preconditioner_cfg:
16854 flags[
'-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg[
'type'])
16856 if 'multigrid' in ps:
16857 mg = ps[
'multigrid']
16858 if not isinstance(mg, dict):
16859 raise ValueError(f
"{source_key}.multigrid must be a mapping when provided.")
16860 mg_map = {
'levels':
'-mg_level',
'pre_sweeps':
'-mg_pre_it',
'post_sweeps':
'-mg_post_it'}
16861 for key, flag
in mg_map.items():
16862 if key
in mg: flags[flag] = mg[key]
16864 cycle = str(mg[
'cycle']).strip().lower()
16865 if cycle
not in {
"v"}:
16866 raise ValueError(f
"{source_key}.multigrid.cycle currently supports only 'v'.")
16868 mode = str(mg[
'mode']).strip().lower()
16869 if mode
not in {
"multiplicative"}:
16870 raise ValueError(f
"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
16871 if 'semi_coarsening' in mg:
16872 sc = mg[
'semi_coarsening']
16873 if not isinstance(sc, dict):
16874 raise ValueError(f
"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
16878 if 'level_solvers' in mg:
16879 level_solvers = mg[
'level_solvers']
16880 if not isinstance(level_solvers, dict):
16881 raise ValueError(f
"{source_key}.multigrid.level_solvers must be a mapping when provided.")
16882 for level_name, settings
in level_solvers.items():
16883 if not isinstance(settings, dict):
16884 raise ValueError(f
"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
16885 level_num = _poisson_level_number(level_name)
16886 for key, value
in settings.items():
16890 'method':
'ksp_type',
'preconditioner':
'pc_type',
16891 'max_it':
'ksp_max_it',
'rtol':
'ksp_rtol',
'atol':
'ksp_atol',
16895 prefix =
"-ps_mg_coarse_"
16896 if mapped_key ==
'ksp_type':
16897 _warn_if_krylov_coarse_solver(value, source_key)
16899 prefix = f
"-ps_mg_levels_{level_num}_"
16902 if 'poisson_solver' in solver_cfg
and 'pressure_solver' in solver_cfg:
16903 if solver_cfg[
'poisson_solver'] != solver_cfg[
'pressure_solver']:
16905 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
16906 "Use 'poisson_solver' only, or make the legacy alias identical."
16908 poisson_cfg = solver_cfg.get(
'poisson_solver', solver_cfg.get(
'pressure_solver'))
16909 if poisson_cfg
is not None:
16910 source_key =
'poisson_solver' if 'poisson_solver' in solver_cfg
else 'pressure_solver'
16911 _append_poisson_solver_flags(poisson_cfg, source_key)
16912 interp_cfg = solver_cfg.get(
'interpolation', {})
16913 if isinstance(interp_cfg, dict):
16914 interp_method_str = interp_cfg.get(
'method',
'Trilinear')
16916 interp_method_str =
'Trilinear'
16918 flags[
'-interpolation_method'] = interp_code
16919 print(f
" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
16921 if 'petsc_passthrough_options' in solver_cfg:
16922 passthrough = solver_cfg[
'petsc_passthrough_options']
16923 if passthrough
is None:
16925 if not isinstance(passthrough, dict):
16926 raise ValueError(
"petsc_passthrough_options must be a mapping when provided.")
16928 for key, value
in passthrough.items():
16929 if str(key).strip() ==
"-ps_mg_coarse_ksp_type":
16930 _warn_if_krylov_coarse_solver(value,
"petsc_passthrough_options")
16933 if '-ps_ksp_type' in flags:
16934 summary_bits.append(f
"method={flags['-ps_ksp_type']}")
16935 if '-ps_ksp_atol' in flags:
16936 summary_bits.append(f
"atol={flags['-ps_ksp_atol']}")
16937 if '-ps_ksp_rtol' in flags:
16938 summary_bits.append(f
"rtol={flags['-ps_ksp_rtol']}")
16939 if '-ps_ksp_max_it' in flags:
16940 summary_bits.append(f
"max_it={flags['-ps_ksp_max_it']}")
16941 if '-mg_level' in flags:
16942 summary_bits.append(f
"mg_levels={flags['-mg_level']}")
16944 print(f
" - Poisson Solver: {', '.join(summary_bits)}")
16945 if selected_solver ==
"DUALTIME_PICARD_JAMESON_RK":
16947 for label, flag
in (
16948 (
"initial_pseudo_cfl",
"-pseudo_cfl"),
16949 (
"pseudo_cfl_range",
None),
16950 (
"max_pseudo_steps",
"-mom_max_pseudo_steps"),
16952 if flag
and flag
in flags:
16953 dualtime_bits.append(f
"{label}={flags[flag]}")
16954 elif label ==
"pseudo_cfl_range" and "-min_pseudo_cfl" in flags
and "-max_pseudo_cfl" in flags:
16955 dualtime_bits.append(f
"pseudo_cfl_range=[{flags['-min_pseudo_cfl']}, {flags['-max_pseudo_cfl']}]")
16956 print(
" - Momentum Solver: Dual Time Picard Jameson RK" +
16957 (f
" ({', '.join(dualtime_bits)})" if dualtime_bits
else ""))
16958 elif selected_solver ==
"newton_krylov":
16960 for label, flag
in (
16961 (
"jacobian",
"-mom_nk_jacobian_type"),
16962 (
"nonlinear",
"-mom_nk_snes_type"),
16963 (
"linear",
"-mom_nk_ksp_type"),
16964 (
"preconditioner",
"-mom_nk_preconditioner_model"),
16967 newton_bits.append(f
"{label}={flags[flag]}")
16968 print(
" - Momentum Solver: Newton Krylov" +
16969 (f
" ({', '.join(newton_bits)})" if newton_bits
else " (PETSc defaults)"))
16971 print(
" - Momentum Solver: Explicit RK (no pseudo-time controller)")
16975 restart_source_dir=None, continue_mode=False,
16976 config_dir: str =
None):
16978 @brief Generates the main .control file for the C-solver.
16979 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
16980 into a single, machine-readable file of command-line flags.
16981 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
16982 @param[in] run_id Argument passed to `generate_solver_control_file()`.
16983 @param[in] configs Argument passed to `generate_solver_control_file()`.
16984 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
16985 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
16986 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
16987 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
16988 @param[in] config_dir Optional configuration revision directory.
16989 @return Value returned by `generate_solver_control_file()`.
16991 print(
"[INFO] Generating master solver control file...")
16992 case_cfg, solver_cfg, monitor_cfg = configs[
'case'], configs[
'solver'], configs[
'monitor']
16993 source_files = {
'Case': configs[
'case_path'],
'Solver': configs[
'solver_path'],
'Monitor': configs[
'monitor_path']}
16997 props, run_ctrl = case_cfg[
'properties'], case_cfg[
'run_control']
16998 scales, fluid, ic = props[
'scaling'], props[
'fluid'], props[
'initial_conditions']
17001 L_ref = fluid_scaling[
"length_ref"]
17002 U_ref = fluid_scaling[
"velocity_ref"]
17003 rho = fluid_scaling[
"density"]
17004 reynolds = fluid_scaling[
"reynolds"]
17005 dt_nondim =
to_solver_units(run_ctrl[
'dt_physical'], (
"case",
"run_control",
"dt_physical"), fluid_scaling)
17007 print(f
" - Reynolds Number (Re) = {reynolds:.4f}")
17008 print(f
" - Non-Dimensional dt* = {dt_nondim:.6f}")
17010 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
17012 start_step = int(run_ctrl.get(
"start_step", 0)
or 0)
17013 ic_is_authoritative = eulerian_source ==
"solve" and start_step == 0
17015 if ic_is_authoritative:
17017 ic, prepared_blocks, fluid_scaling,
17018 provider_context={
"kinematic_viscosity": fluid_scaling[
"nondimensional_kinematic_viscosity"]},
17020 finit_mode_str = resolved_ic[
"label"]
17021 finit_code = resolved_ic[
"finit"]
17022 ic_params = resolved_ic[
"cli_params"]
17023 print(f
" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
17024 if "ucont_x" in ic_params:
17026 f
"-ucont_x {ic_params['ucont_x']}",
17027 f
"-ucont_y {ic_params['ucont_y']}",
17028 f
"-ucont_z {ic_params['ucont_z']}",
17030 if "ic_velocity_physical" in ic_params:
17031 ic_cli.append(f
"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
17032 if "flow_direction" in ic_params:
17033 ic_cli.append(f
"-flow_direction {ic_params['flow_direction']}")
17035 source = (f
"the run starts from the checkpoint at step {start_step}" if start_step > 0
17036 else f
"eulerian_field_source={eulerian_source!r} reads stored fields")
17037 print(f
"[INFO] Initial condition not applied: {source}.")
17040 control_lines.extend([
17041 f
"-start_step {run_ctrl['start_step']}", f
"-totalsteps {run_ctrl['total_steps']}",
17042 f
"-ren {reynolds}", f
"-dt {dt_nondim}", f
"-finit {finit_code}",
17044 f
"-scaling_L_ref {L_ref}", f
"-scaling_U_ref {U_ref}", f
"-scaling_rho_ref {rho}"
17046 except (KeyError, TypeError, ZeroDivisionError, ValueError)
as e:
17047 print(f
"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
17051 if monitor_files.get(
"whitelist"):
17052 control_lines.append(f
"-whitelist_config_file {monitor_files['whitelist']}")
17053 if monitor_files.get(
"profile"):
17054 control_lines.append(f
"-profile_config_file {monitor_files['profile']}")
17057 profiling_cfg = monitor_files.get(
"profiling", {})
17058 control_lines.append(f
"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
17059 control_lines.append(f
"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
17060 if profiling_cfg.get(
"helpers"):
17061 control_lines.append(f
"-profiling_helpers {','.join(profiling_cfg['helpers'])}")
17063 memory_log_cfg = diagnostics_cfg[
"runtime_memory_log"]
17064 control_lines.append(f
"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
17066 walltime_guard_policy = configs.get(
"walltime_guard_policy")
17067 if walltime_guard_policy
is not None:
17068 control_lines.extend(
17070 f
"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
17071 f
"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
17072 f
"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
17073 f
"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
17074 f
"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
17078 grid_cfg = case_cfg.get(
'grid', {})
17079 grid_mode = grid_cfg.get(
'mode')
17080 expected_nblk = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
17082 if grid_mode ==
'file':
17083 print(
"[INFO] Grid Mode: Using external file...")
17084 case_file_dir = os.path.dirname(configs[
'case_path'])
17085 nondim_grid_path = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
17086 if os.path.isfile(nondim_grid_path):
17087 print(f
"[INFO] Reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
17088 control_lines.append(f
"-grid_file {nondim_grid_path}")
17091 grid_for_validation = source_grid
17094 grid_for_validation, nondim_grid_path, grid_length_scale, expected_nblk=expected_nblk
17097 f
"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
17098 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
17100 control_lines.append(f
"-grid_file {nondim_grid_path}")
17101 except Exception
as e:
17102 print(f
"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
17104 elif grid_mode ==
'grid_gen':
17105 nondim_grid_path = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
17106 if os.path.isfile(nondim_grid_path):
17107 print(f
"[INFO] Grid Mode: grid.gen, reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
17108 control_lines.append(f
"-grid_file {nondim_grid_path}")
17110 print(
"[INFO] Grid Mode: Generating external grid via grid.gen...")
17114 configs[
'case_path'], run_dir, grid_cfg, case_cfg=case_cfg)
17116 generated_grid, nondim_grid_path, grid_length_scale, expected_nblk=expected_nblk
17119 f
"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
17120 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
17122 control_lines.append(f
"-grid_file {nondim_grid_path}")
17123 except Exception
as e:
17124 print(f
"[FATAL] Grid generation failed: {e}", file=sys.stderr)
17126 elif grid_mode ==
'programmatic_c':
17127 print(
"[INFO] Grid Mode: Programmatic C...")
17129 control_lines.append(
"-grid")
17130 for p_key
in GRID_DA_PROCESSOR_KEYS:
17131 grid_settings.pop(p_key,
None)
17132 for key, value
in grid_settings.items(): control_lines.append(f
"-{key} {format_flag_value(value)}")
17140 nondim_grid_path = os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
17141 if not os.path.isfile(nondim_grid_path):
17144 grid_cfg.get(
'programmatic_settings', {}), nondim_grid_path,
17148 "[SUCCESS] Materialized a bridge grid for the Python initial-condition "
17149 f
"provider: {os.path.relpath(nondim_grid_path)}"
17151 except Exception
as e:
17152 print(f
"[FATAL] Failed to materialize bridge grid for initial-condition generator: {e}",
17156 raise ValueError(f
"Unknown or missing grid mode '{grid_mode}' in case.yml.")
17161 except Exception
as e:
17162 print(f
"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
17164 control_lines.extend([
17165 f
"-ic_field {resolved_ic['field_code']}",
17166 f
"-ic_dir {staged_ic['directory']}",
17168 print(f
" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
17172 run_dir, run_id, case_cfg, source_files, config_dir=config_dir
17174 except ValueError
as e:
17175 print(f
"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
17177 control_lines.append(f
"-bcs_files \"{','.join(bcs_files)}\"")
17180 control_lines, grid_cfg, num_procs,
17182 case_cfg, configs[
"case_path"], os.path.join(run_dir,
"inputs",
"grid",
"grid.run")),
17184 explicit=configs.get(
"decomposition"), cluster_cfg=configs.get(
"cluster_cfg"))
17189 if 'solver_parameters' in case_cfg:
17190 params = case_cfg[
'solver_parameters']
17192 for key, value
in params.items():
17193 control_lines.append(f
"{key} {format_flag_value(value)}")
17197 except ValueError
as e:
17198 print(f
"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
17204 except ValueError
as e:
17205 print(f
"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
17210 solves_flow = solver_flags.get(
'-euler_field_source',
'"solve"') ==
'"solve"'
17211 if not solves_flow:
17212 inactive = [flag
for flag
in (*SOLVER_MONITORING_MOMENTUM_FLAG_MAP.values(),
17213 *SOLVER_MONITORING_POISSON_FLAG_MAP.values())
17214 if solver_monitoring_flags.pop(flag,
None)
is not None]
17216 print(
"[INFO] monitor.yml solver_monitoring applies to solved flow only; not passed "
17217 "with a loaded or analytical Eulerian field.")
17218 elif solver_flags.get(
'-mom_solver_type') !=
'"newton_krylov"':
17219 inactive = [flag
for flag
in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.values()
17220 if solver_monitoring_flags.pop(flag,
None)
is not None]
17222 print(
"[INFO] monitor.yml solver_monitoring.momentum applies to Newton-Krylov only; "
17223 "not passed to this momentum solver.")
17226 io_cfg = monitor_cfg.get(
'io', {})
17228 if 'data_output_frequency' in io_cfg: control_lines.append(f
"-tio {io_cfg['data_output_frequency']}")
17231 for key
in (
'write_initial_checkpoint',
'write_final_checkpoint'):
17233 control_lines.append(f
"-{key} {str(bool(io_cfg[key])).lower()}")
17234 if particle_console_output_freq
is not None:
17235 control_lines.append(f
"-particle_console_output_freq {particle_console_output_freq}")
17237 if statistics_console_output_freq
is not None:
17238 control_lines.append(f
"-statistics_console_output_freq {statistics_console_output_freq}")
17239 if 'particle_log_interval' in io_cfg: control_lines.append(f
"-logfreq {io_cfg['particle_log_interval']}")
17242 control_lines.append(f
"-picurv_trace_particles {','.join(str(pid) for pid in traced)}")
17243 if restart_source_dir:
17244 expected_restart = os.path.abspath(
17245 os.path.join(run_dir, CANONICAL_RUN_PATHS[
"restart"])
17247 if os.path.abspath(restart_source_dir) != expected_restart:
17249 "Restart data was not materialized in the canonical run input: "
17250 f
"expected {expected_restart}, got {restart_source_dir}."
17253 control_lines.append(
"-continue_mode true")
17254 elif str(configs.get(
"statistics_state",
"reset")).lower() ==
"carry":
17255 control_lines.append(
"-field_statistics_continue true")
17261 control_lines.append(
"")
17262 control_lines.append(
"# Canonical run-owned directories are fixed by the workspace contract.")
17263 control_lines.append(f
"-output_dir {CANONICAL_RUN_PATHS['output']}")
17264 control_lines.append(f
"-restart_dir {CANONICAL_RUN_PATHS['restart']}")
17265 control_lines.append(f
"-log_dir {CANONICAL_RUN_PATHS['logs']}")
17266 control_lines.append(f
"-analysis_dir {CANONICAL_RUN_PATHS['metrics']}")
17268 final_content =
generate_header(run_id, source_files) +
"\n".join(control_lines)
17269 config_dir = config_dir
or os.path.join(run_dir,
"config")
17270 os.makedirs(config_dir, exist_ok=
True)
17271 control_file_path = os.path.join(config_dir, f
"{run_id}.control")
17272 with open(control_file_path,
"w")
as f: f.write(final_content)
17273 print(f
"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
17274 return os.path.abspath(control_file_path)
17277 cluster_cfg:
"dict | None" =
None) -> dict:
17279 @brief Resolve the postprocessor's MPI decomposition from the run's staged configuration.
17280 @details The postprocessor builds the same grid hierarchy as the solve, from the run's
17281 active case and solver files and its staged grid, so its decomposition is
17282 resolved like the solve's, for its own rank count.
17283 @param[in] run_dir Run directory.
17284 @param[in] num_procs MPI ranks of the post-processing stage.
17285 @param[in] explicit (ni, nj, nk) from the command line, or None.
17286 @param[in] cluster_cfg Parsed cluster.yml, or None.
17287 @return The resolved decomposition (resolve_decomposition()).
17290 case_cfg =
read_yaml_file(active[
"case"])
if active.get(
"case")
and os.path.isfile(active[
"case"])
else {}
17291 solver_cfg =
read_yaml_file(active[
"solver"])
if active.get(
"solver")
and os.path.isfile(active[
"solver"])
else {}
17292 block_dims =
resolve_grid_node_dims(case_cfg, active.get(
"case")
or os.path.join(run_dir,
"config",
"case.yml"),
17293 os.path.join(run_dir,
"inputs",
"grid",
"grid.run"))
17299 decomposition:
"dict | None" =
None) -> str:
17301 @brief Generates a key=value config file (post.run) for the C post-processor.
17302 @details Translates the structured post-processing YAML into the specific flat
17303 key-value format required by the C executable, including complex,
17304 semicolon-separated pipeline strings.
17305 @param[in] run_dir The path to the main run directory.
17306 @param[in] run_id The unique identifier for the run.
17307 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
17308 @param[in] source_files A dictionary of source files for the header.
17309 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
17310 @param[in] decomposition The postprocessor's resolved decomposition (resolve_post_decomposition()),
17311 or None to let PETSc decide.
17312 @return The absolute path to the generated post.run recipe file.
17314 print(
"[INFO] Generating post-processor recipe file (post.run)...")
17315 if not isinstance((post_cfg
or {}).get(
"_picurv_paths"), dict):
17318 os.makedirs(config_dir, exist_ok=
True)
17319 post_recipe_path = os.path.join(config_dir,
"post.run")
17324 for key, value
in c_config.items():
17325 if value
is not None and str(value) !=
"":
17326 lines.append(f
"{key} = {value}")
17328 lines.append(f
"step_list_file = {get_post_step_list_path(run_dir, post_cfg)}")
17329 if decomposition
and decomposition.get(
"layout")
and decomposition[
"layout"] != (1, 1, 1):
17330 lines.append(
"da_processors = " +
" ".join(str(v)
for v
in decomposition[
"layout"]))
17332 with open(post_recipe_path,
"w")
as f:
17333 f.write(
"\n".join(lines))
17334 print(f
"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
17335 return os.path.abspath(post_recipe_path)
17339 @brief Executes a command, streaming its output to the console and a log file.
17341 If None, the process inherits the parent's environment directly.
17342 @param[in] command Argument passed to `execute_command()`.
17343 @param[in] run_dir Argument passed to `execute_command()`.
17344 @param[in] log_filename Argument passed to `execute_command()`.
17345 @param[in] monitor_cfg Argument passed to `execute_command()`.
17348 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
17350 print(f
"[INFO] Launching Command...\n > {format_command_for_display(command)}")
17351 print(f
" Log file: {os.path.relpath(log_path)}")
17356 "stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
17357 "cwd": run_dir,
"bufsize": 1,
"universal_newlines":
True,
17358 "encoding":
'utf-8',
"errors":
'replace'
17361 run_env = os.environ.copy()
17363 verbosity = monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()
17364 run_env[
'LOG_LEVEL'] = verbosity
17365 print(f
"[INFO] Log level: {verbosity}")
17367 for token
in command:
17370 run_env[
"LD_LIBRARY_PATH"] = library_dir + (
":" + run_env[
"LD_LIBRARY_PATH"]
17371 if run_env.get(
"LD_LIBRARY_PATH")
else "")
17373 popen_kwargs[
'env'] = run_env
17378 process = subprocess.Popen(command, **popen_kwargs)
17380 with open(log_path,
"w")
as log_file:
17381 for line
in process.stdout:
17382 sys.stdout.write(line)
17383 log_file.write(line)
17385 return_code = process.returncode
17387 if return_code == 0:
17388 print(f
"[SUCCESS] Execution finished successfully.")
17390 print(f
"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
17391 sys.exit(return_code)
17392 except FileNotFoundError:
17393 print(f
"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
17394 print(
" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
17396 except Exception
as e:
17397 print(f
"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
17403 @brief Render a shell-safe command string for console and log output.
17404 @param[in] command Argument passed to `format_command_for_display()`.
17405 @return Value returned by `format_command_for_display()`.
17407 return " ".join(shlex.quote(str(part))
for part
in command)
17412 @brief Resolve a command log filename relative to the run directory.
17413 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
17414 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
17415 @return Value returned by `resolve_command_log_path()`.
17417 if os.path.dirname(log_filename):
17418 return os.path.join(run_dir, log_filename)
17419 return os.path.join(run_dir,
"logs", log_filename)
17424 @brief Raised when an external command exits unsuccessfully.
17427 def __init__(self, command: list, returncode: int, details: str =
None):
17429 @brief Initialize a command execution error.
17430 @param[in] command Argument passed to `__init__()`.
17431 @param[in] returncode Argument passed to `__init__()`.
17432 @param[in] details Argument passed to `__init__()`.
17437 detail_suffix = f
": {details}" if details
else ""
17439 f
"Command failed with exit code {returncode}: {format_command_for_display(command)}{detail_suffix}"
17445 @brief Raised when plot.gen reports a missing optional dependency.
17451 @brief Run a command and capture combined stdout/stderr details for later inspection.
17452 @param[in] command Argument passed to `_run_captured_command()`.
17453 @param[in] run_dir Argument passed to `_run_captured_command()`.
17454 @return Value returned by `_run_captured_command()`.
17457 return subprocess.run(
17461 capture_output=
True,
17466 except FileNotFoundError
as exc:
17467 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
17472 @brief Raise `CommandExecutionError` when a captured command failed.
17473 @param[in] command Argument passed to `_require_successful_command()`.
17474 @param[in] result Argument passed to `_require_successful_command()`.
17476 if result.returncode == 0:
17478 details = (result.stderr
or result.stdout).strip()
17484 @brief Run a command, require success, and return stripped stdout text.
17485 @param[in] command Argument passed to `_capture_command_stdout()`.
17486 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
17487 @return Value returned by `_capture_command_stdout()`.
17491 return result.stdout.strip()
17496 @brief Stream command output to stdout and an already-open log file.
17497 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
17498 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
17499 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
17502 print(f
"[INFO] Running: {display}")
17503 log_file.write(f
"$ {display}\n")
17507 "stdout": subprocess.PIPE,
17508 "stderr": subprocess.STDOUT,
17511 "universal_newlines":
True,
17512 "encoding":
"utf-8",
17513 "errors":
"replace",
17517 process = subprocess.Popen(command, **popen_kwargs)
17518 except FileNotFoundError
as exc:
17519 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
17522 for line
in process.stdout:
17523 sys.stdout.write(line)
17524 log_file.write(line)
17525 return_code = process.wait()
17526 log_file.write(
"\n")
17528 if return_code != 0:
17534 @brief Capture the current git HEAD branch name and commit hash.
17535 @param[in] run_dir Argument passed to `_get_git_head_state()`.
17536 @return Value returned by `_get_git_head_state()`.
17539 branch_result =
_run_captured_command([
"git",
"symbolic-ref",
"--quiet",
"--short",
"HEAD"], run_dir)
17540 branch_name = branch_result.stdout.strip()
if branch_result.returncode == 0
else None
17541 return {
"branch": branch_name,
"commit": head_commit}
17546 @brief Return local branch names plus their configured upstreams.
17547 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
17548 @return Value returned by `_get_local_branches_with_upstreams()`.
17551 [
"git",
"for-each-ref",
"--sort=refname",
"--format=%(refname:short)\t%(upstream:short)",
"refs/heads"],
17555 for line
in output.splitlines():
17556 if not line.strip():
17558 branch_name, _, upstream_name = line.partition(
"\t")
17559 branches.append((branch_name, upstream_name
or None))
17565 @brief Return `True` when the repository has staged or unstaged tracked changes.
17566 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
17567 @return Value returned by `_working_tree_has_tracked_changes()`.
17569 command = [
"git",
"status",
"--porcelain",
"--untracked-files=no"]
17572 return bool(result.stdout.strip())
17577 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
17578 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
17579 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
17580 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
17582 cleanup_command = [
"git",
"rebase",
"--abort"]
if rebase
else [
"git",
"merge",
"--abort"]
17584 if result.returncode == 0:
17585 print(f
"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
17586 log_file.write(f
"$ {format_command_for_display(cleanup_command)}\n")
17588 sys.stdout.write(result.stdout)
17589 log_file.write(result.stdout)
17591 sys.stderr.write(result.stderr)
17592 log_file.write(result.stderr)
17593 log_file.write(
"\n")
17597 details = (result.stderr
or result.stdout).strip()
17600 f
"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
17602 print(message, file=sys.stderr)
17603 log_file.write(message +
"\n")
17609 @brief Restore the repository back to the branch or detached commit it started on.
17610 @param[in] run_dir Argument passed to `_restore_git_head()`.
17611 @param[in] original_head Argument passed to `_restore_git_head()`.
17612 @param[in] log_file Argument passed to `_restore_git_head()`.
17615 if original_head[
"branch"]:
17616 if current_state[
"branch"] == original_head[
"branch"]:
17621 if current_state[
"branch"]
is None and current_state[
"commit"] == original_head[
"commit"]:
17628 @brief Refuse a branch pull when the source checkout is pinned to a detached commit.
17630 @details `versions install` and `versions activate` leave the checkout detached on
17631 the version they built. A branch pull there either fails inside Git or, in
17632 the multi-branch path, updates the branches and then restores the detached
17633 commit, so the active code never changes while the command reports success.
17634 A directory that is not a Git checkout is left for the Git command to report.
17635 @param[in] source_project_root PICurv source checkout to inspect.
17636 @param[in] action User-facing command name.
17637 @throws ValueError when HEAD is detached.
17639 branch =
_run_captured_command([
"git",
"symbolic-ref",
"--quiet",
"--short",
"HEAD"], source_project_root)
17640 if branch.returncode == 0:
17642 commit =
_run_captured_command([
"git",
"rev-parse",
"--short=12",
"--verify",
"HEAD"], source_project_root)
17643 if commit.returncode != 0:
17646 f
"{action}: the source checkout at {source_project_root} is pinned to commit "
17647 f
"{commit.stdout.strip()}, not a branch, so a branch pull would not change the code "
17649 " Install a newer version with 'picurv versions install <tag-or-commit>', "
17650 "or check out a branch (for example 'git checkout main') to follow it."
17656 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
17657 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
17658 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
17659 @param[in] rebase Argument passed to `pull_all_source_branches()`.
17662 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
17664 print(
"\n" +
"="*23 +
" PULL SOURCE STAGE " +
"="*22)
17665 print(
"[INFO] Refreshing all local source branches that track an upstream.")
17666 print(f
" Log file: {os.path.relpath(log_path)}")
17672 raise RuntimeError(
17673 "Multi-branch pull requires a clean tracked working tree in the source repository. "
17674 "Commit or stash those changes first, or rerun with --current-branch-only."
17677 except (CommandExecutionError, RuntimeError)
as exc:
17678 print(f
"[FATAL] {exc}", file=sys.stderr)
17679 sys.exit(getattr(exc,
"returncode", 1))
17682 print(
"[FATAL] No local branches were found in the source repository.", file=sys.stderr)
17685 if original_head[
"branch"]:
17686 branches = [item
for item
in branches
if item[0] != original_head[
"branch"]] + [
17687 item
for item
in branches
if item[0] == original_head[
"branch"]
17690 skipped_branches = []
17691 current_operation =
None
17693 restore_error =
None
17695 with open(log_path,
"w", encoding=
"utf-8")
as log_file:
17696 log_file.write(f
"# PICurv pull-source all-branch sync\n")
17697 log_file.write(f
"# repository: {os.path.abspath(run_dir)}\n")
17698 log_file.write(f
"# started: {datetime.now().isoformat()}\n")
17700 f
"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
17704 for branch_name, upstream_name
in branches:
17705 if not upstream_name:
17706 warning = f
"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
17707 print(warning, file=sys.stderr)
17708 log_file.write(warning +
"\n")
17709 skipped_branches.append(branch_name)
17712 print(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
17713 log_file.write(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
17715 current_operation = f
"checkout:{branch_name}"
17718 pull_command = [
"git",
"pull"]
17720 pull_command.append(
"--rebase")
17721 current_operation = f
"pull:{branch_name}"
17723 current_operation =
None
17724 except CommandExecutionError
as exc:
17726 if current_operation
and current_operation.startswith(
"pull:"):
17731 except CommandExecutionError
as exc:
17732 restore_error = exc
17738 f
"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
17739 f
"Check log: {os.path.relpath(log_path)}",
17742 sys.exit(restore_error.returncode)
17744 f
"[FATAL] Multi-branch pull failed. Original branch restored. "
17745 f
"Check log: {os.path.relpath(log_path)}",
17748 sys.exit(pull_error.returncode)
17752 f
"[FATAL] Branch updates completed, but the original branch could not be restored. "
17753 f
"Check log: {os.path.relpath(log_path)}",
17756 sys.exit(restore_error.returncode)
17758 if skipped_branches:
17759 print(f
"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
17760 print(
"[SUCCESS] All local tracking branches are up to date.")
17764 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
17765 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
17766 @return Value returned by `auto_identify_run_inputs()`.
17768 run_dir = os.path.dirname(os.path.abspath(config_dir))
17771 case_path = active.get(
"case")
17772 monitor_path = active.get(
"monitor")
17773 solver_control_path = active.get(
"control")
17774 if all(path
and os.path.isfile(path)
for path
in (case_path, monitor_path, solver_control_path)):
17775 return case_path, monitor_path, solver_control_path
17776 all_yml_files = glob.glob(os.path.join(config_dir,
"*.yml"))
17777 case_path, monitor_path =
None,
None
17778 for f_path
in all_yml_files:
17781 if not isinstance(content, dict):
17783 if 'models' in content
and 'boundary_conditions' in content:
17785 elif 'io' in content
and 'logging' in content:
17786 monitor_path = f_path
17787 except Exception
as e:
17788 print(f
"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
17790 solver_control_path = glob.glob(os.path.join(config_dir,
"*.control"))[0]
17792 solver_control_path =
None
17793 return case_path, monitor_path, solver_control_path
17797 @brief Resolve post source directory token and optionally enforce existence.
17798 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
17799 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
17800 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
17801 @param[in] strict Argument passed to `resolve_post_source_directory()`.
17802 @return Value returned by `resolve_post_source_directory()`.
17804 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"])
17806 if source_dir_template ==
'<solver_output_dir>':
17807 resolved_source_dir = solver_output_dir_abs
17808 print(f
"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
17810 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
17811 print(f
"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
17813 if strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
17815 f
"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
17819 if not strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
17820 print(
"[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
17821 return resolved_source_dir
17828 case_index_tsv: str,
17834 expected_version_line:
"str | None" =
None,
17837 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
17838 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
17839 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
17840 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
17841 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
17842 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
17843 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
17844 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
17845 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
17846 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
17847 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
17848 @param[in] expected_version_line `--version` output the stage's executable reported
17849 at staging; checked before launch in every task.
17850 @return Value returned by `render_slurm_array_stage_script()`.
17852 effective_cluster_cfg = cluster_cfg
17853 resources = effective_cluster_cfg.get(
"resources", {})
17854 notifications = effective_cluster_cfg.get(
"notifications", {})
or {}
17855 execution = effective_cluster_cfg.get(
"execution", {})
or {}
17856 module_setup = execution.get(
"module_setup", [])
or []
17857 extra_sbatch = execution.get(
"extra_sbatch")
17861 f
"#SBATCH --job-name={job_name}",
17862 f
"#SBATCH --nodes={resources['nodes']}",
17863 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
17864 f
"#SBATCH --mem={resources['mem']}",
17865 f
"#SBATCH --time={resources['time']}",
17866 f
"#SBATCH --output={stdout_path}",
17867 f
"#SBATCH --error={stderr_path}",
17868 f
"#SBATCH --account={resources['account']}",
17869 f
"#SBATCH --array={array_spec}",
17871 partition = resources.get(
"partition")
17873 lines.append(f
"#SBATCH --partition={partition}")
17874 mail_user = notifications.get(
"mail_user")
17875 mail_type = notifications.get(
"mail_type")
17877 lines.append(f
"#SBATCH --mail-user={mail_user}")
17879 lines.append(f
"#SBATCH --mail-type={mail_type}")
17880 if isinstance(extra_sbatch, dict):
17881 for key, value
in extra_sbatch.items():
17883 if not flag.startswith(
"--"):
17885 if isinstance(value, bool):
17887 lines.append(f
"#SBATCH {flag}")
17888 elif value
is not None:
17889 lines.append(f
"#SBATCH {flag}={value}")
17890 elif isinstance(extra_sbatch, list):
17891 for token
in extra_sbatch:
17892 lines.append(f
"#SBATCH {token}")
17896 "set -euo pipefail",
17898 f
'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
17899 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
17900 'if [ -z "$LINE" ]; then',
17901 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
17904 "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\"",
17906 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
17909 if stage ==
"solve":
17911 for key, value
in walltime_guard_exports.items():
17912 lines.append(f
"export {key}={value}")
17914 lines.append(
'export LOG_LEVEL="${LOG_LEVEL}"')
17916 for setup_line
in module_setup:
17917 lines.append(str(setup_line))
17919 stage_exe = solver_exe
if stage ==
"solve" else post_exe
17922 stage_exe
if not stage_exe.startswith(
"$")
else
17925 stage_exe, expected_version_line, pinned=stage_exe.startswith(
"$RUN_DIR/")
17928 if stage ==
"solve":
17930 effective_cluster_cfg,
17932 [
"-control_file",
"$CONTROL_FILE"]
17936 effective_cluster_cfg,
17938 [
"-control_file",
"$CONTROL_FILE",
"-postprocessing_config_file",
"$POST_RECIPE_FILE"],
17943 def _token(tok: str) -> str:
17945 @brief Preserve shell-variable tokens while safely quoting literal command arguments for an sbatch script.
17946 @param[in] tok Argument passed to `_token()`.
17947 @return Value returned by `_token()`.
17949 if tok.startswith(
"$"):
17951 return shlex.quote(str(tok))
17953 diag_var =
"${SOLVE_DIAGNOSTIC_ARGS}" if stage ==
"solve" else "${POST_DIAGNOSTIC_ARGS}"
17954 command_text =
" ".join(_token(t)
for t
in cmd)
17955 executable_token = _token(solver_exe
if stage ==
"solve" else post_exe)
17958 if executable_token
and command_text.count(executable_token) == 1:
17959 command_text = command_text.replace(f
"{executable_token} ", f
"{executable_token} {diag_var} ", 1)
17960 if stage ==
"post":
17966 f
"{shlex.quote(sys.executable)} \"{lock_paths['wrapper_path']}\" "
17967 f
"--lock-file \"{lock_paths['lock_file']}\" "
17968 f
"--metadata-file \"{lock_paths['metadata_file']}\" "
17969 f
"--run-dir \"$RUN_DIR\" --post-recipe \"$RUN_DIR/config/post.yml\" "
17970 f
"--postprocessor {executable_token} -- {command_text}"
17976 f
"{shlex.quote(sys.executable)} -c {shlex.quote(_post_finalize_python_source())} "
17977 '"$RUN_DIR" "$RUN_DIR/config/post.yml" reducers'
17980 lines.append(f
"exec {command_text}")
17982 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
17983 with open(script_path,
"w")
as f:
17984 f.write(
"\n".join(lines) +
"\n")
17985 os.chmod(script_path, 0o755)
17996 @brief Generate a single-node sbatch script that runs metrics aggregation.
17997 @param[in] script_path Path to write the sbatch script.
17998 @param[in] job_name Slurm job name.
17999 @param[in] cluster_cfg Parsed cluster YAML dictionary.
18000 @param[in] study_dir Absolute path to the study directory.
18001 @param[in] picurv_path Absolute path to the picurv script.
18003 resources = cluster_cfg.get(
"resources", {})
18004 notifications = cluster_cfg.get(
"notifications", {})
or {}
18005 execution = cluster_cfg.get(
"execution", {})
or {}
18006 module_setup = execution.get(
"module_setup", [])
or []
18008 scheduler_dir = os.path.join(study_dir,
"scheduler")
18011 f
"#SBATCH --job-name={job_name}",
18012 "#SBATCH --nodes=1",
18013 "#SBATCH --ntasks-per-node=1",
18014 "#SBATCH --mem=4G",
18015 "#SBATCH --time=00:10:00",
18016 f
"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
18017 f
"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
18018 f
"#SBATCH --account={resources['account']}",
18020 partition = resources.get(
"partition")
18022 lines.append(f
"#SBATCH --partition={partition}")
18023 mail_user = notifications.get(
"mail_user")
18024 mail_type = notifications.get(
"mail_type")
18026 lines.append(f
"#SBATCH --mail-user={mail_user}")
18028 lines.append(f
"#SBATCH --mail-type={mail_type}")
18032 "set -euo pipefail",
18033 'echo "[$(date)] Running metrics aggregation"',
18036 for setup_line
in module_setup:
18037 lines.append(str(setup_line))
18040 f
"exec {shlex.quote(picurv_path)} sweep --reaggregate"
18041 f
" --study-dir {shlex.quote(study_dir)}"
18044 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
18045 with open(script_path,
"w")
as f:
18046 f.write(
"\n".join(lines) +
"\n")
18047 os.chmod(script_path, 0o755)
18052 @brief Reduce a metric series to one scalar according to the requested reducer.
18053 @param[in] values Sequence of numeric values.
18054 @param[in] reduction Reduction keyword.
18055 @return Value returned by `reduce_metric_values()`.
18060 reduction = str(reduction).lower()
18061 if reduction ==
"mean":
18062 return float(np.mean(values))
18063 if reduction ==
"min":
18064 return float(np.min(values))
18065 if reduction ==
"max":
18066 return float(np.max(values))
18067 if reduction ==
"p95":
18068 return float(np.percentile(values, 95.0))
18069 return float(values[-1])
18074 @brief Extract a scalar metric from a CSV source.
18075 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
18076 @param[in] spec Argument passed to `extract_metric_from_csv()`.
18077 @return Value returned by `extract_metric_from_csv()`.
18079 file_glob = spec.get(
"file_glob",
"**/*_msd.csv")
18080 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
18083 csv_path = candidates[0]
18085 with open(csv_path,
"r", newline=
"")
as f:
18086 reader = csv.DictReader(f)
18087 if reader.fieldnames:
18092 column = spec.get(
"column")
18093 numerator_column = spec.get(
"numerator_column")
18094 denominator_column = spec.get(
"denominator_column")
18095 denominator_floor = float(spec.get(
"denominator_floor", 0.0)
or 0.0)
18096 if not column
and not numerator_column:
18097 for name
in reversed(reader.fieldnames):
18098 if name
and name.lower()
not in {
"step",
"time",
"timestep",
"physical_time"}:
18101 if not column
and not numerator_column:
18106 if numerator_column:
18107 numerator = float(row[numerator_column])
18108 denominator = float(row[denominator_column])
18109 denominator = max(denominator_floor, denominator)
18110 if denominator == 0.0:
18112 values.append(numerator / denominator)
18114 values.append(float(row[column]))
18124 @brief Extract a scalar metric from a log file using regex.
18125 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
18126 @param[in] spec Argument passed to `extract_metric_from_log()`.
18127 @return Value returned by `extract_metric_from_log()`.
18129 file_glob = spec.get(
"file_glob",
"logs/*.log")
18130 regex = spec.get(
"regex")
18133 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
18136 pattern = re.compile(regex)
18138 for path
in candidates:
18140 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
18142 m = pattern.search(line)
18145 values.append(float(m.group(1)))
18155 @brief Normalize study metric definitions to a common dictionary form.
18156 @param[in] metric Argument passed to `normalize_metric_spec()`.
18157 @return Value returned by `normalize_metric_spec()`.
18159 if isinstance(metric, str):
18160 if metric.lower()
in {
"msd",
"msd_final"}:
18162 "name":
"msd_final",
18163 "source":
"statistics_csv",
18164 "file_glob":
"**/*_msd.csv",
18166 "column":
"MSD_total",
18167 "reduction":
"last",
18169 return {
"name": metric,
"source":
"log_regex",
"regex": metric}
18170 return dict(metric)
18174 @brief Read the metrics table an earlier aggregation wrote, keyed by case id.
18175 @param[in] results_dir Study analysis directory holding metrics_table.csv.
18176 @return Mapping of case id to its previously recorded row, empty when absent.
18178 path = os.path.join(results_dir,
"metrics_table.csv")
18179 if not os.path.isfile(path):
18182 with open(path,
"r", encoding=
"utf-8", newline=
"")
as stream:
18184 row[
"case_id"]: row
for row
in csv.DictReader(stream)
if row.get(
"case_id")
18186 except (OSError, ValueError):
18192 @brief Collect metric values from generated case directories into one CSV.
18193 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
18194 @param[in] cases Argument passed to `aggregate_study_metrics()`.
18195 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
18196 @return Value returned by `aggregate_study_metrics()`.
18198 metrics = study_cfg.get(
"metrics", [])
18200 metrics = [
"msd_final"]
18209 row = {
"case_id": case[
"case_id"]}
18211 for p_key, p_val
in flat_parameters.items():
18213 if is_artifact_cold(case[
"run_dir"]):
18214 retained = preserved.get(case[
"case_id"])
18216 for spec
in normalized_specs:
18217 name = spec.get(
"name",
"metric")
18218 row[name] = retained.get(name)
18219 row[
"_source"] =
"retained"
18223 f
"[WARNING] {case['case_id']} is in cold storage and no previous metrics "
18224 "row was found; its values are reported as unavailable.",
18227 for spec
in normalized_specs:
18228 row[spec.get(
"name",
"metric")] =
None
18231 for spec
in normalized_specs:
18232 name = spec.get(
"name",
"metric")
18233 source = str(spec.get(
"source",
"")).lower()
18234 if source
in METRIC_SOURCE_KINDS[:2]:
18236 elif source
in METRIC_SOURCE_KINDS[2:]:
18241 normalize_key = spec.get(
"normalize_by_parameter")
18242 if value
is not None and normalize_key:
18243 denom = flat_parameters.get(normalize_key)
18245 denom = float(denom)
18248 if denom
not in (
None, 0.0):
18249 value = float(value) / denom
18262 for k
in row.keys():
18267 os.makedirs(results_dir, exist_ok=
True)
18268 out_csv = os.path.join(results_dir,
"metrics_table.csv")
18269 with open(out_csv,
"w", newline=
"")
as f:
18270 writer = csv.DictWriter(f, fieldnames=all_keys)
18271 writer.writeheader()
18272 writer.writerows(rows)
18273 metric_names = [spec.get(
"name",
"metric")
for spec
in normalized_specs]
18274 if any(row.get(name)
is not None for row
in rows
for name
in metric_names):
18275 print(f
"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
18278 print(f
"[INFO] Initialized metrics table (no member has produced metrics yet): "
18279 f
"{os.path.relpath(out_csv)}")
18282_STUDY_REPORT_COLORS = (
18283 "#0072B2",
"#D55E00",
"#009E73",
"#CC79A7",
18284 "#E69F00",
"#56B4E9",
"#332288",
"#999999",
18290 @brief Return a concise report label for a study parameter path.
18291 @param[in] key Dotted study parameter path.
18292 @return Report-facing axis or legend label.
18295 "case.run_control.dt_physical":
"Physical timestep, Δt",
18296 "case.models.physics.particles.count":
"Particle count",
18297 "solver.operation_mode.uniform_flow.u":
"Uniform-flow velocity, u",
18298 "case.grid.programmatic_settings.im":
"Grid nodes in i, Nᵢ",
18299 "case.grid.programmatic_settings.jm":
"Grid nodes in j, Nⱼ",
18300 "case.grid.programmatic_settings.km":
"Grid nodes in k, Nₖ",
18307 @brief Resolve an optional configured metric label or humanize its name.
18308 @param[in] study_cfg Parsed study configuration.
18309 @param[in] metric Metric column name.
18310 @return Report-facing metric label.
18312 for raw_spec
in study_cfg.get(
"metrics", [])
or []:
18314 if spec.get(
"name") != metric:
18316 label = spec.get(
"plot_label")
or spec.get(
"label")
18317 units = spec.get(
"units")
18319 return f
"{base} ({units})" if units
else base
18325 @brief Parse one complete, finite numeric study-table column.
18326 @param[in] rows Metrics-table rows.
18327 @param[in] key Column name.
18328 @return Numeric values, or None when the column is incomplete or nonnumeric.
18333 value = float(row[key])
18334 except (KeyError, TypeError, ValueError):
18336 if not math.isfinite(value):
18338 values.append(value)
18344 @brief Infer the scientifically meaningful independent variable of a study.
18345 @param[in] study_cfg Parsed study configuration.
18346 @param[in] rows Metrics-table rows.
18347 @return Axis metadata, or None when no numeric independent variable exists.
18350 if not params
or not rows:
18354 "case.grid.programmatic_settings.im",
18355 "case.grid.programmatic_settings.jm",
18356 "case.grid.programmatic_settings.km",
18358 if study_cfg.get(
"study_type") ==
"grid_independence" and all(key
in params
for key
in grid_keys):
18360 if all(column
is not None for column
in columns):
18362 (columns[0][index] * columns[1][index] * columns[2][index]) ** (1.0 / 3.0)
18363 for index
in range(len(rows))
18366 "key":
"characteristic_grid_resolution",
18367 "label":
"Characteristic grid resolution, (NᵢNⱼNₖ)¹⁄³",
18368 "slug":
"characteristic_grid_resolution",
18370 "contributors": set(grid_keys),
18374 if study_cfg.get(
"study_type") ==
"timestep_independence":
18375 preferred = [key
for key
in params
if key.endswith(
".dt_physical")]
18377 for key
in preferred + [key
for key
in params
if key
not in preferred]:
18379 if values
is not None:
18380 numeric.append((key, values, len(set(values))))
18383 varied = [candidate
for candidate
in numeric
if candidate[2] > 1]
18384 key, values, _unique = (varied
or numeric)[0]
18388 "slug": re.sub(
r"[^A-Za-z0-9_.-]+",
"_", key),
18390 "contributors": {key},
18396 @brief Infer x-axis key/values for study plots.
18397 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
18398 @param[in] rows Argument passed to `infer_plot_x_axis()`.
18399 @return Value returned by `infer_plot_x_axis()`.
18404 return axis[
"label"], axis[
"values"]
18409 @brief Format a secondary study parameter compactly for a legend.
18410 @param[in] value Parameter value.
18411 @return Compact report string.
18414 number = float(value)
18415 except (TypeError, ValueError):
18417 if number.is_integer():
18418 return f
"{int(number):,}"
18419 return f
"{number:g}"
18424 @brief Group metric points by any secondary varied study parameters.
18425 @param[in] study_cfg Parsed study configuration.
18426 @param[in] rows Metrics-table rows.
18427 @param[in] axis Inferred independent-axis metadata.
18428 @param[in] metric Metric column name.
18429 @return Labeled point groups.
18433 for key
in param_keys:
18434 if key
in axis[
"contributors"]:
18436 values = {str(row.get(key))
for row
in rows}
18441 if 1 < len(values) < len(rows):
18442 secondary.append(key)
18445 for row_index, row
in enumerate(rows):
18447 y_value = float(row[metric])
18448 except (KeyError, TypeError, ValueError):
18450 if not math.isfinite(y_value):
18452 identity = tuple(row.get(key)
for key
in secondary)
18453 groups.setdefault(identity, []).append([axis[
"values"][row_index], y_value])
18456 for identity, points
in groups.items():
18457 points.sort(key=
lambda point: point[0])
18459 f
"{_study_parameter_label(key)} = {_format_study_group_value(value)}"
18460 for key, value
in zip(secondary, identity)
18462 result.append({
"label": label
or "Study cases",
"points": points})
18468 @brief Use log scaling only for positive data spanning a meaningful range.
18469 @param[in] values Candidate axis values.
18470 @param[in] semantic_hint Whether the quantity is conventionally read logarithmically.
18471 @return True when logarithmic scaling improves interpretation.
18473 if not values
or any(value <= 0.0
for value
in values):
18475 ratio = max(values) / min(values)
18476 return ratio >= (20.0
if semantic_hint
else 100.0)
18481 @brief Build padded linear limits that include zero for non-negative metrics.
18482 @param[in] values Finite metric values.
18483 @return Lower and upper limits, or None for an empty sequence.
18487 lower, upper = min(values), max(values)
18490 span = upper - lower
18491 padding = max(span * 0.06, abs(upper) * 0.02, 1.0e-12)
18492 return (lower, upper + padding)
if lower == 0.0
else (lower - padding, upper + padding)
18496 @brief Generate metric-vs-parameter plots for completed studies.
18497 @param[in] study_cfg Argument passed to `generate_study_plots()`.
18498 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
18499 @param[in] plots_dir Argument passed to `generate_study_plots()`.
18500 @return Value returned by `generate_study_plots()`.
18502 plotting_cfg = study_cfg.get(
"plotting", {})
or {}
18503 if plotting_cfg.get(
"enabled",
True)
is False:
18504 print(
"[INFO] Plotting disabled by study.yml.")
18508 print(
"[WARNING] matplotlib not available; skipping plot generation.")
18510 if not metrics_csv
or not os.path.isfile(metrics_csv):
18513 with open(metrics_csv,
"r", newline=
"")
as f:
18514 reader = csv.DictReader(f)
18515 rows =
list(reader)
18521 print(
"[WARNING] Could not infer numeric x-axis for plots; skipping.")
18524 configured_metrics = study_cfg.get(
"metrics", [])
or [
"msd_final"]
18530 out_format = plotting_cfg.get(
"output_format",
"png")
18531 os.makedirs(plots_dir, exist_ok=
True)
18533 for metric
in metric_keys:
18537 all_x = [point[0]
for group
in groups
for point
in group[
"points"]]
18538 all_y = [point[1]
for group
in groups
for point
in group[
"points"]]
18540 metric_hint = metric.lower()
18543 any(token
in metric_hint
for token
in (
"error",
"residual",
"drift",
"msd")),
18546 grid_cross_product = (
18547 study_cfg.get(
"study_type") ==
"grid_independence"
18548 and bool(study_cfg.get(
"parameters"))
and len(axis[
"contributors"]) == 3
18550 len(set(float(row[key])
for row
in rows)) > 1
18551 for key
in axis[
"contributors"]
18555 plt.figure(figsize=(9.2, 5.5), facecolor=
"white")
18556 for index, group
in enumerate(groups):
18557 x_values = [point[0]
for point
in group[
"points"]]
18558 y_values = [point[1]
for point
in group[
"points"]]
18559 repeated_x = len(set(x_values)) != len(x_values)
18561 x_values, y_values,
18562 color=_STUDY_REPORT_COLORS[index % len(_STUDY_REPORT_COLORS)],
18563 marker=
"o", markersize=5.2, markeredgewidth=0.7,
18564 linewidth=0.0
if repeated_x
or grid_cross_product
else 1.8,
18565 linestyle=
"none" if repeated_x
or grid_cross_product
else "-",
18566 label=group[
"label"],
18568 plt.xlabel(axis[
"label"], fontsize=11)
18569 plt.ylabel(metric_label, fontsize=11)
18571 "grid_independence":
"Grid-independence study",
18572 "timestep_independence":
"Timestep-independence study",
18573 "sensitivity":
"Sensitivity study",
18574 }.get(study_cfg.get(
"study_type"),
"Parameter study")
18575 plt.title(f
"{metric_label} vs. {axis['label']}\n{study_label}", fontsize=13, loc=
"left", pad=12)
18576 if x_log
and hasattr(plt,
"xscale"):
18580 elif hasattr(plt,
"ylim"):
18584 if hasattr(plt,
"tick_params"):
18585 plt.tick_params(axis=
"both", which=
"major", labelsize=9.5)
18586 plt.grid(
True, alpha=0.24, which=
"major", linewidth=0.7)
18588 plt.grid(
True, alpha=0.08, which=
"minor", linewidth=0.5)
18589 if len(groups) > 1:
18591 loc=
"upper left", bbox_to_anchor=(1.01, 1.0), borderaxespad=0.0,
18592 frameon=
False, title=
"Fixed parameters", fontsize=9, title_fontsize=9.5,
18594 plt.tight_layout(rect=(0.0, 0.0, 0.76, 1.0))
18597 safe_metric = re.sub(
r"[^A-Za-z0-9_.-]+",
"_", metric)
18598 out_path = os.path.join(plots_dir, f
"{safe_metric}_vs_{axis['slug']}.{out_format}")
18599 plt.savefig(out_path, dpi=240, bbox_inches=
"tight", facecolor=
"white")
18601 generated.append(out_path)
18603 print(f
"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
18609 @brief Render a command list as a shell-safe display string.
18610 @param[in] command_tokens Argument passed to `_command_to_string()`.
18611 @return Value returned by `_command_to_string()`.
18613 return " ".join(shlex.quote(str(tok))
for tok
in command_tokens)
18618 @brief Resolve post source directory without side effects or stdout/stderr output.
18619 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
18620 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
18621 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
18622 @return Value returned by `_resolve_post_source_directory_preview()`.
18624 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"])
18626 if source_dir_template ==
'<solver_output_dir>':
18627 return solver_output_dir_abs
18628 return os.path.abspath(os.path.join(run_dir, source_dir_template))
18633 @brief Build a no-write execution plan for `run --dry-run`.
18634 @param[in] args Command-line style argument list supplied to the function.
18635 @return Value returned by `build_run_dry_plan()`.
18639 "created_at": datetime.now().isoformat(),
18646 if args.dry_run
and args.no_submit:
18647 plan[
"warnings"].append(
"--dry-run takes precedence over --no-submit; no files will be written.")
18649 cluster_mode = bool(getattr(args,
"cluster",
None))
18651 cluster_path =
None
18652 solver_num_procs_effective = args.num_procs
18653 post_num_procs_effective = args.num_procs
18656 solver_control_path =
None
18657 loaded_case_cfg =
None
18658 loaded_monitor_cfg =
None
18659 resolved_restart_source_dir =
None
18662 cluster_path = os.path.abspath(args.cluster)
18665 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
18666 if args.scheduler
and args.scheduler.lower() != scheduler_type:
18668 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18669 key=
"scheduler.type",
18670 file_path=cluster_path,
18671 message=f
"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
18674 if scheduler_type !=
"slurm":
18676 ERROR_CODE_CFG_INVALID_VALUE,
18677 key=
"scheduler.type",
18678 file_path=cluster_path,
18679 message=f
"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported.",
18683 if (args.solve
or args.post_process)
and args.num_procs
not in (1, cluster_tasks):
18685 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18686 key=
"resources.ntasks_per_node",
18687 file_path=cluster_path,
18689 "--num-procs must be 1 (auto) or "
18690 f
"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
18695 solver_num_procs_effective = cluster_tasks
18696 if args.post_process:
18697 post_num_procs_effective = cluster_tasks
18698 plan[
"launch_mode"] =
"slurm"
18699 plan[
"inputs"][
"cluster"] = cluster_path
18701 if getattr(args,
"scheduler",
None):
18702 fail_cli_usage(
"--scheduler requires --cluster in this version.")
18703 plan[
"launch_mode"] =
"local"
18707 if getattr(args,
'restart_from',
None):
18708 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
18709 if getattr(args,
'continue_run',
False):
18710 print(
"[WARNING] --continue only extends a solve in place and is ignored here. "
18711 "Post-processing always skips steps whose output is up to date; drop "
18712 "--continue from post-only commands.", file=sys.stderr)
18715 case_path = os.path.abspath(args.case)
18720 solver_path = os.path.abspath(args.solver)
18721 monitor_path = os.path.abspath(args.monitor)
18726 loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path,
18727 restart_source_given=bool(getattr(args,
'restart_from',
None)
or getattr(args,
'continue_run',
False)),
18730 continue_mode = getattr(args,
'continue_run',
False)
18734 if not args.run_dir:
18736 run_dir = os.path.abspath(args.run_dir)
18737 if not os.path.isdir(run_dir):
18739 ERROR_CODE_CFG_FILE_NOT_FOUND,
18742 message=
"Specified run directory not found.",
18746 run_id = os.path.basename(run_dir)
18750 run_dir = os.path.join(runs_root, run_id)
18754 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir,
18757 plan[
"lineage"] = planned_lineage
or {
"relationship":
"root"}
18759 args, loaded_case_cfg, case_path, loaded_monitor_cfg, resolved_restart_source_dir)
18760 except ValueError
as e:
18762 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18764 file_path=case_path,
18766 hint=_STORAGE_RESTORE_HINT
if isinstance(e.__cause__, StorageError)
else _RESTART_HINT,
18770 config_dir = os.path.join(run_dir,
"config")
18771 scheduler_dir = os.path.join(run_dir,
"scheduler")
18776 logs_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"logs"])
18777 planned_output_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"output"])
18778 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
18779 profile_path = os.path.join(config_dir,
"profile.run")
18782 plan[
"run_id_preview"] = run_id
18783 plan[
"run_dir_preview"] = run_dir
18787 loaded_case_cfg, case_path,
18788 os.path.join(run_dir,
"inputs",
"grid",
"grid.run")
if continue_mode
else None)
18790 solver_num_procs_effective, loaded_case_cfg.get(
"grid")
or {}, block_dims, solver_cfg,
18792 cluster_cfg
if cluster_mode
else None)
18793 plan.setdefault(
"blocking", []).extend(decomposition[
"problems"])
18794 plan[
"decomposition"] = {
"layout":
list(decomposition[
"layout"])
if decomposition.get(
"layout")
else None,
18795 "source": decomposition[
"source"],
18797 if block_dims
is None and solver_num_procs_effective > 1:
18798 plan[
"warnings"].append(
18799 "Could not determine the grid's dimensions without building it, so the "
18800 "decomposition was not checked against the multigrid levels.")
18805 plan.setdefault(
"blocking", [])
18809 plan[
"blocking"].extend(structure_errors)
18810 plan[
"warnings"].extend(structure_warnings)
18813 if _authorized
and _verdict
in WAIVABLE_PHYSICAL_VERDICTS:
18814 plan[
"warnings"].append(f
"{_message} Allowed only because "
18815 f
"'allow_unsafe_paths: true' is set.")
18817 plan[
"blocking"].append(_message)
18818 plan[
"inputs"].update({
"case": case_path,
"solver": solver_path,
"monitor": monitor_path})
18822 plan[
"asset_actions"] = [
18823 {
"kind": item[
"kind"],
"provider": item[
"provider"],
"action": item[
"action"]}
18824 for item
in asset_plan[
"actions"]
18827 item[
"kind"]
for item
in asset_plan[
"actions"]
18828 if item[
"execution"] ==
"runtime-c"
18830 for item
in asset_plan[
"actions"]:
18831 blocked_dependencies = runtime_kinds.intersection(item.get(
"dependencies", []))
18832 if item[
"execution"] ==
"precomputable" and blocked_dependencies:
18833 plan[
"blocking"].append(
18834 f
"{item['kind']}={item['provider']} requires a file-backed "
18835 f
"{', '.join(sorted(blocked_dependencies))}, but that dependency is generated "
18836 "only inside the simulator. Select a precomputable provider for the dependency."
18838 plan[
"artifacts"].extend(
18841 *(os.path.join(run_dir, *relative.split(
"/"))
18842 for relative
in RUN_DIRECTORY_LAYOUT),
18843 os.path.join(config_dir,
"case.yml"),
18844 os.path.join(config_dir,
"solver.yml"),
18845 os.path.join(config_dir,
"monitor.yml"),
18846 os.path.join(config_dir,
"active.json"),
18847 os.path.join(run_dir, CANONICAL_RUN_PATHS[
"inputs"],
"assets.lock.yml"),
18848 solver_control_path,
18849 os.path.join(run_dir,
"manifest.json"),
18856 plan[
"artifacts"].append(os.path.join(config_dir,
"whitelist.run"))
18857 if profiling_preview[
"mode"] ==
"selected":
18858 plan[
"artifacts"].append(profile_path)
18860 plan[
"artifacts"].extend(solve_diagnostics[
"artifacts"])
18862 plan[
"artifacts"].append(os.path.join(config_dir,
"cluster.yml"))
18863 plan[
"artifacts"].append(os.path.join(scheduler_dir,
"submission.json"))
18866 solver_exe = executable_plan[
"paths"][
"simulator"]
18867 if executable_plan[
"pinning"] ==
"new":
18868 plan[
"artifacts"].extend(executable_plan[
"paths"].values())
18871 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
18876 config_search_anchor=case_path,
18877 extra_search_anchors=[cluster_path],
18879 plan[
"artifacts"].append(solver_script)
18880 plan[
"stages"][
"solve"] = {
18882 "script": solver_script,
18883 "num_procs_effective": solver_num_procs_effective,
18884 "launch_command": solver_cmd,
18891 solver_num_procs_effective,
18892 config_search_anchor=case_path,
18894 solver_stream_log = os.path.join(scheduler_dir, f
"{run_id}_solver.log")
18895 plan[
"artifacts"].append(solver_stream_log)
18896 plan[
"stages"][
"solve"] = {
18898 "num_procs_effective": solver_num_procs_effective,
18899 "stream_log": solver_stream_log,
18900 "launch_command": solver_cmd,
18903 plan[
"stages"][
"solve"][
"executable_pinning"] = executable_plan[
"pinning"]
18904 plan[
"stages"][
"solve"][
"petsc_index_bits"] = 8 * planned_index_bytes
18905 planned_run_control = loaded_case_cfg.get(
"run_control")
or {}
18906 plan[
"stages"][
"solve"][
"start_step"] = int(planned_run_control.get(
"start_step", 0)
or 0)
18907 plan[
"stages"][
"solve"][
"end_step"] = (plan[
"stages"][
"solve"][
"start_step"]
18908 + int(planned_run_control.get(
"total_steps", 0)
or 0))
18909 if resolved_restart_source_dir:
18910 plan[
"stages"][
"solve"][
"restart_source_directory"] = resolved_restart_source_dir
18912 plan[
"stages"][
"solve"][
"continue_mode"] =
True
18914 if args.post_process:
18915 post_path = os.path.abspath(args.post)
18916 plan[
"inputs"][
"post"] = post_path
18921 run_dir = os.path.abspath(args.run_dir)
18922 if not os.path.isdir(run_dir):
18924 ERROR_CODE_CFG_FILE_NOT_FOUND,
18927 message=
"Specified run directory not found.",
18931 run_id = os.path.basename(run_dir)
18932 elif not args.solve:
18933 fail_cli_usage(
"--post-process requires --run-dir when not used with --solve.")
18936 config_dir = os.path.join(run_dir,
"config")
18938 if not all([case_path, monitor_path, solver_control_path]):
18940 ERROR_CODE_CFG_MISSING_KEY,
18941 key=
"run_dir.config",
18942 file_path=config_dir,
18944 "Could not auto-identify required run inputs "
18945 "(case.yml/monitor.yml/*.control) in run config directory."
18952 config_dir = os.path.join(run_dir,
"config")
18953 case_path = os.path.join(config_dir,
"case.yml")
18954 monitor_path = os.path.join(config_dir,
"monitor.yml")
18955 if solver_control_path
is None:
18956 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
18959 allow_source_frontier_scan =
not args.solve
18960 recompute = bool(getattr(args,
'recompute_post',
False))
18962 post_exe = executable_plan[
"paths"][
"postprocessor"]
18966 except ValueError
as e:
18968 file_path=run_dir, message=str(e))
18975 loaded_monitor_cfg,
18977 recompute=recompute,
18978 allow_source_frontier_scan=allow_source_frontier_scan,
18984 post_num_procs_effective, loaded_case_cfg.get(
"grid")
or {}, block_dims, solver_cfg,
18986 cluster_cfg
if cluster_mode
else None)
18989 run_dir, post_num_procs_effective, getattr(args,
"decomposition",
None),
18990 cluster_cfg
if cluster_mode
else None)
18991 plan.setdefault(
"blocking", []).extend(
18992 problem
for problem
in post_decomposition[
"problems"]
if problem
not in plan[
"blocking"])
18993 plan[
"post_decomposition"] = {
18994 "layout":
list(post_decomposition[
"layout"])
if post_decomposition.get(
"layout")
else None,
18995 "source": post_decomposition[
"source"],
19001 output_dir_rel = post_cfg.get(
"io", {}).get(
"output_directory")
19002 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix")
19003 if not output_prefix:
19005 ERROR_CODE_CFG_MISSING_KEY,
19006 key=
"io.output_filename_prefix",
19007 file_path=post_path,
19008 message=
"Missing required post output filename prefix.",
19011 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
19014 plan[
"artifacts"].extend(post_diagnostics[
"artifacts"])
19017 solver_control_path,
19018 "-postprocessing_config_file",
19021 plan[
"artifacts"].extend([
19023 archived_post_path,
19025 post_plan[
"resume_state_path"],
19026 post_plan[
"lock_paths"][
"wrapper_path"],
19027 post_plan[
"lock_paths"][
"lock_file"],
19028 post_plan[
"lock_paths"][
"metadata_file"],
19030 plan[
"artifacts"].extend(statistics_output_paths)
19031 if post_finalize_command:
19032 io_cfg = post_cfg.get(
"io", {})
or {}
19034 plan[
"artifacts"].append(os.path.join(
19035 output_dir_abs, f
"{io_cfg.get('output_filename_prefix', 'Field')}.pvd"
19038 plan[
"artifacts"].append(os.path.join(
19039 output_dir_abs, f
"{io_cfg.get('particle_filename_prefix', 'Particle')}.pvd"
19043 "source_data_directory": post_plan[
"source_data_directory"],
19044 "requested_start_step": post_plan[
"requested_start_step"],
19045 "requested_end_step": post_plan[
"requested_end_step"],
19046 "step_interval": post_plan[
"step_interval"],
19047 "recompute": recompute,
19048 "steps_to_process": post_plan[
"steps_to_process"],
19049 "steps_current": post_plan[
"steps_current"],
19050 "steps_other_build": post_plan[
"steps_other_build"],
19051 "steps_unavailable": post_plan[
"steps_unavailable"],
19052 "process_reasons": post_plan[
"process_reasons"],
19053 "source_frontier_step": post_plan[
"source_frontier_step"],
19054 "source_frontier_diagnostic": post_plan[
"source_frontier_diagnostic"],
19055 "source_frontier_deferred": post_plan[
"source_frontier_deferred"],
19056 "effective_start_step": post_plan[
"effective_start_step"],
19057 "effective_end_step": post_plan[
"effective_end_step"],
19058 "skip_reason": post_plan[
"skip_reason"],
19059 "post_skipped_as_complete": post_plan[
"skip_reason"] ==
"already-complete-window",
19060 "recipe_fingerprint": post_plan[
"recipe_fingerprint"],
19061 "num_procs_effective": post_num_procs_effective,
19062 "finalize_command": post_finalize_command,
19065 if post_plan[
"skip_reason"]
is None:
19067 scheduler_dir = os.path.join(run_dir,
"scheduler")
19068 post_script = os.path.join(scheduler_dir,
"post.sbatch")
19069 post_cluster_cfg = cluster_cfg
19074 config_search_anchor=case_path,
19075 extra_search_anchors=[cluster_path],
19076 force_num_procs=post_num_procs_effective,
19080 post_plan[
"recipe_fingerprint"],
19082 create_wrapper=
False,
19083 success_command=post_finalize_command,
19084 post_recipe=archived_post_path,
19085 postprocessor=post_exe,
19086 recompute=recompute,
19088 plan[
"artifacts"].append(post_script)
19089 stage_meta.update({
19091 "script": post_script,
19092 "launch_command": post_cmd,
19099 post_num_procs_effective,
19100 config_search_anchor=case_path,
19101 allow_single_rank_launcher_override=
True,
19102 force_num_procs=post_num_procs_effective,
19106 post_plan[
"recipe_fingerprint"],
19108 create_wrapper=
False,
19109 success_command=post_finalize_command,
19110 post_recipe=archived_post_path,
19111 postprocessor=post_exe,
19112 recompute=recompute,
19114 post_stream_log = os.path.join(run_dir,
"scheduler", f
"{run_id}_{output_prefix}.log")
19115 plan[
"artifacts"].append(post_stream_log)
19116 stage_meta.update({
19118 "stream_log": post_stream_log,
19119 "launch_command": post_cmd,
19123 stage_meta.update({
19124 "mode":
"slurm" if cluster_mode
else "local",
19125 "launch_command": [],
19126 "launch_command_string":
"",
19129 plan[
"stages"][
"post-process"] = stage_meta
19134 for item
in plan[
"artifacts"]:
19135 if item
not in seen:
19137 deduped.append(item)
19138 plan[
"artifacts"] = deduped
19139 if run_id
and "run_id_preview" not in plan:
19140 plan[
"run_id_preview"] = run_id
19141 if run_dir
and "run_dir_preview" not in plan:
19142 plan[
"run_dir_preview"] = run_dir
19143 plan[
"solver_num_procs_effective"] = solver_num_procs_effective
19144 plan[
"post_num_procs_effective"] = post_num_procs_effective
19145 plan[
"num_procs_effective"] = solver_num_procs_effective
19151 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
19152 @param[in,out] plan Dry-run plan to update.
19153 @param[in] case_cfg Parsed case configuration.
19154 @param[in] run_dir Preview run directory for relative artifact resolution.
19156 grid_cfg = case_cfg.get(
"grid", {})
19157 if not isinstance(grid_cfg, dict):
19160 mode = grid_cfg.get(
"mode")
19161 grid_dir = os.path.join(run_dir,
"inputs",
"grid")
19164 plan[
"artifacts"].append(os.path.join(grid_dir,
"grid.run"))
19165 elif mode ==
"grid_gen":
19166 generator = grid_cfg.get(
"generator", {})
19167 if not isinstance(generator, dict):
19169 plan[
"artifacts"].extend([
19170 os.path.join(grid_dir,
"grid.run"),
19171 os.path.join(grid_dir,
"grid.generated.picgrid"),
19172 os.path.join(run_dir,
"output",
"analysis",
"metrics",
"grid.info"),
19173 os.path.join(run_dir,
"output",
"visualization",
"precompute",
"grid.vts"),
19178 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
19179 @param[in,out] plan Dry-run plan to update.
19180 @param[in] case_cfg Parsed case configuration.
19181 @param[in] run_dir Preview run directory.
19187 profile_dir = os.path.join(run_dir,
"inputs",
"inlet_profiles")
19188 has_generated =
False
19189 for block_idx, block
in enumerate(prepared_blocks):
19191 if bc.get(
"handler") !=
"prescribed_flow":
19193 source = (bc.get(
"params")
or {}).get(
"source", {})
19194 if source.get(
"type")
not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
19196 has_generated =
True
19198 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
19199 generated_path = os.path.join(
19200 profile_dir, f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice"
19202 staged_path = os.path.join(profile_dir, f
"inlet_profile_block{block_idx}_{face_token}.picslice")
19203 plan[
"artifacts"].append(generated_path)
19204 plan[
"artifacts"].append(staged_path)
19206 plan[
"artifacts"].append(os.path.join(profile_dir,
"profile.info"))
19210 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
19211 @param[in,out] plan Dry-run plan receiving artifact paths.
19212 @param[in] case_cfg Parsed case configuration.
19213 @param[in] solver_cfg Parsed solver configuration.
19214 @param[in] run_dir Planned run directory.
19217 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
19219 start_step = int((case_cfg.get(
"run_control", {})
or {}).get(
"start_step", 0)
or 0)
19220 if source !=
"solve" or start_step != 0:
19225 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
19227 scales=fluid_scaling,
19228 provider_context={
"kinematic_viscosity": fluid_scaling[
"nondimensional_kinematic_viscosity"]},
19230 except (KeyError, ValueError):
19234 initial_dir = os.path.join(run_dir,
"inputs",
"initial_condition")
19235 plan[
"artifacts"].append(
19236 os.path.join(initial_dir, f
"{resolved['field_name']}00000_0.dat")
19239 if (case_cfg.get(
"grid", {})
or {}).get(
"mode") ==
"programmatic_c":
19240 plan[
"artifacts"].append(os.path.join(run_dir,
"inputs",
"grid",
"grid.run"))
19241 plan[
"artifacts"].append(os.path.join(initial_dir,
"initial_condition.generated.dat"))
19247 @brief Render dry-run plan in human or JSON format.
19248 @param[in] plan Dry-run plan produced by build_run_dry_plan().
19249 @param[in] output_format Either "text" or "json".
19250 @return 1 when the plan carries blocking findings, 0 otherwise.
19252 if output_format ==
"json":
19253 print(json.dumps(plan, indent=2, sort_keys=
True))
19254 return 1
if plan.get(
"blocking")
else 0
19256 for message
in plan.get(
"warnings", []):
19257 print(f
"[WARNING] {message}", file=sys.stderr)
19258 if plan.get(
"blocking"):
19259 print(
"[FATAL] This configuration would be refused. The plan below is what the "
19260 "run WOULD do; it will not get that far:", file=sys.stderr)
19261 for message
in plan[
"blocking"]:
19262 print(f
" {message}", file=sys.stderr)
19264 print(
"\n" +
"=" * 60)
19265 print(
" DRY-RUN PLAN")
19267 print(f
" Launch mode : {plan.get('launch_mode')}")
19268 print(f
" Created at : {plan.get('created_at')}")
19269 if plan.get(
"run_id_preview"):
19270 print(f
" Run ID preview : {plan.get('run_id_preview')}")
19271 if plan.get(
"run_dir_preview"):
19272 print(f
" Run dir preview : {plan.get('run_dir_preview')}")
19273 lineage = plan.get(
"lineage")
or {}
19274 if lineage.get(
"relationship") ==
"branch":
19275 print(f
" Branched from : {lineage.get('parent_run_id')} "
19276 f
"@ step {lineage.get('checkpoint_step')} "
19277 f
"(statistics: {lineage.get('statistics_state')})")
19278 planned_stages = plan.get(
"stages")
or {}
19279 if "solve" in planned_stages:
19280 print(f
" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
19281 if "start_step" in planned_stages[
"solve"]:
19282 print(f
" Solver steps : {planned_stages['solve']['start_step']} -> "
19283 f
"{planned_stages['solve']['end_step']}")
19284 if "post-process" in planned_stages:
19285 print(f
" Post MPI procs : {plan.get('post_num_procs_effective')}")
19286 for key, label
in ((
"decomposition",
"Solver"), (
"post_decomposition",
"Post")):
19288 print(f
" {label} {plan[key]['summary']}")
19289 if plan.get(
"warnings"):
19290 print(
" Warnings :")
19291 for warning
in plan[
"warnings"]:
19292 print(f
" - {warning}")
19294 if plan.get(
"inputs"):
19295 print(
"\n Inputs:")
19296 for key, value
in plan[
"inputs"].items():
19297 print(f
" - {key}: {value}")
19299 if plan.get(
"stages"):
19300 print(
"\n Planned stage commands:")
19301 for stage, details
in plan[
"stages"].items():
19302 print(f
" - {stage} ({details.get('mode')}):")
19303 if details.get(
'skip_reason'):
19304 print(f
" skipped: {details.get('skip_reason')}")
19306 print(f
" {details.get('launch_command_string')}")
19308 if plan.get(
"asset_actions"):
19309 print(
"\n Asset resolution:")
19310 for item
in plan[
"asset_actions"]:
19311 print(f
" - {item['kind']}: {item['action']} ({item['provider']})")
19313 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_FILE]
19314 if diagnostics_artifacts:
19315 print(
"\n Diagnostics artifacts:")
19316 for artifact
in diagnostics_artifacts:
19317 print(f
" - {artifact}")
19319 print(
"\n Planned artifacts (no files created in dry-run):")
19320 for artifact
in plan.get(
"artifacts", []):
19321 print(f
" - {artifact}")
19327 @brief Implements `picurv validate` without launching solver/post workflows.
19328 @param[in] args Command-line style argument list supplied to the function.
19331 solver_group_selected = any([args.case, args.solver, args.monitor])
19332 any_group_selected = solver_group_selected
or any([args.post, args.cluster, args.study])
19334 cluster_path =
None
19336 if getattr(args,
'continue_run',
False)
and not getattr(args,
'run_dir',
None):
19338 if not any_group_selected:
19340 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
19341 hint=
"Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
19344 if solver_group_selected
and not all([args.case, args.solver, args.monitor]):
19345 fail_cli_usage(
"When solver validation is requested, --case, --solver, and --monitor are all required.")
19348 restart_from = getattr(args,
'restart_from',
None)
19349 continue_run = getattr(args,
'continue_run',
False)
19350 run_dir_val = getattr(args,
'run_dir',
None)
19351 if not solver_group_selected:
19353 print(
"[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
19355 print(
"[WARNING] --continue validates a solve continued in place and needs "
19356 "--case/--solver/--monitor; it is ignored here.", file=sys.stderr)
19360 if solver_group_selected:
19361 case_path = os.path.abspath(args.case)
19362 solver_path = os.path.abspath(args.solver)
19363 monitor_path = os.path.abspath(args.monitor)
19368 case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path,
19369 restart_source_given=bool(restart_from
or continue_run),
19371 checked.extend([case_path, solver_path, monitor_path])
19374 if restart_from
or continue_run:
19375 target_run_dir = os.path.abspath(run_dir_val)
if run_dir_val
else os.path.abspath(
"runs/_validate_dummy")
19378 print(
"[SUCCESS] Restart source validation passed.")
19379 except ValueError
as e:
19380 print(f
"[FATAL] Restart validation failed: {e}", file=sys.stderr)
19385 post_path = os.path.abspath(args.post)
19388 checked.append(post_path)
19392 cluster_path = os.path.abspath(args.cluster)
19395 checked.append(cluster_path)
19399 if solver_group_selected:
19400 grid_cfg = case_cfg.get(
"grid")
or {}
19406 layout_problems = decomposition[
"problems"]
19407 if not layout_problems:
19408 print(f
"[INFO] {describe_decomposition(decomposition, tasks)}")
19412 if layout_problems:
19414 ERROR_CODE_CFG_INCONSISTENT_COMBO,
19415 key=
"grid.da_processors",
19416 file_path=case_path,
19417 message=
" ".join(layout_problems),
19421 print(f
"[WARNING] {case_path}: could not determine the grid's dimensions without building "
19422 "it, so the da_processors layout was not checked against the multigrid levels.",
19428 extra_search_anchors=[cluster_path]
if cluster_path
else None,
19430 except ValueError
as exc:
19432 ERROR_CODE_CFG_INVALID_VALUE,
19433 key=
"runtime_execution",
19434 file_path=case_path
or cluster_path
or os.getcwd(),
19438 if runtime_execution_path:
19439 checked.append(runtime_execution_path)
19443 study_path = os.path.abspath(args.study)
19446 checked.append(study_path)
19448 if post_cfg
is not None and run_dir_val:
19449 post_path = os.path.abspath(args.post)
19450 validate_run_dir = os.path.abspath(run_dir_val)
19451 if os.path.isdir(validate_run_dir):
19452 monitor_for_post = monitor_cfg
if solver_group_selected
else None
19453 if monitor_for_post
is None:
19454 config_dir_candidate = os.path.join(validate_run_dir,
"config")
19455 monitor_candidate = os.path.join(config_dir_candidate,
"monitor.yml")
19456 if os.path.isfile(monitor_candidate):
19458 if monitor_for_post
is not None:
19460 if os.path.isdir(resolved_source)
and os.listdir(resolved_source):
19461 print(f
"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
19463 print(f
"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
19465 if post_cfg
is not None:
19468 source_dir = (post_cfg.get(
"source_data")
or {}).get(
"directory")
19469 if source_dir
and source_dir !=
"<solver_output_dir>":
19470 print(f
"[WARNING] {os.path.abspath(args.post)}: source_data.directory '{source_dir}' is "
19471 "ignored; post-processing always reads the run's own output. Remove the key.",
19474 if args.strict
and study_cfg
is not None:
19475 study_path = os.path.abspath(args.study)
19476 base_cfgs = study_cfg.get(
"base_configs", {})
19477 if isinstance(base_cfgs, dict):
19478 base_case_path =
resolve_path(study_path, base_cfgs.get(
"case"))
19479 base_solver_path =
resolve_path(study_path, base_cfgs.get(
"solver"))
19480 base_monitor_path =
resolve_path(study_path, base_cfgs.get(
"monitor"))
19481 base_post_path =
resolve_path(study_path, base_cfgs.get(
"post"))
19482 if all([base_case_path, base_solver_path, base_monitor_path]):
19493 read_yaml_file(base_monitor_path)
if base_monitor_path
else None,
19496 print(f
"[SUCCESS] Validation completed for {len(checked)} file(s).")
19497 for path
in checked:
19498 print(f
" - {path}")
19500ASSET_KIND_DIRECTORIES = {
19502 "initial-condition":
"initial_conditions",
19503 "inlet-profiles":
"inlet_profiles",
19509 @brief Hash an asset source or payload without loading it into memory.
19510 @param[in] path File to hash.
19511 @return Lowercase SHA-256 digest.
19513 digest = hashlib.sha256()
19514 with open(path,
"rb")
as stream:
19515 for block
in iter(
lambda: stream.read(8 * 1024 * 1024), b
""):
19516 digest.update(block)
19517 return digest.hexdigest()
19522 @brief Hash a JSON-compatible value with deterministic serialization.
19523 @param[in] payload Value to hash.
19524 @return Lowercase SHA-256 digest.
19526 encoded = json.dumps(payload, sort_keys=
True, separators=(
",",
":"), default=str).encode(
"utf-8")
19527 return hashlib.sha256(encoded).hexdigest()
19532 @brief Hash every existing file explicitly referenced by an asset provider.
19533 @param[in] value Provider subtree to inspect.
19534 @param[in] case_path Owning case configuration path.
19535 @param[in] key Current dotted key for diagnostics.
19536 @return Mapping of dotted path identity to path/content fingerprint.
19539 if isinstance(value, dict):
19540 for child_key, child
in value.items():
19541 dotted = f
"{key}.{child_key}" if key
else str(child_key)
19544 if isinstance(value, list):
19545 for index, child
in enumerate(value):
19548 basename = key.rsplit(
".", 1)[-1]
19549 if basename
not in _ASSET_SOURCE_REFERENCE_KEYS
or not isinstance(value, str)
or not value.strip():
19555 if os.path.isfile(resolved):
19558 os.path.relpath(resolved, workspace_root).replace(os.sep,
"/")
19559 if workspace_root
and os.path.commonpath([workspace_root, resolved]) == workspace_root
19568 @brief Classify case inputs into precomputable or simulator-runtime providers.
19569 @param[in] case_cfg Parsed case configuration.
19570 @param[in] case_path Owning case YAML path.
19571 @return Provider graph with stable identities and explicit dependencies.
19574 grid_cfg = copy.deepcopy(case_cfg.get(
"grid", {})
or {})
19575 grid_mode = str(grid_cfg.get(
"mode",
"")).strip().lower()
19576 if isinstance(grid_cfg.get(
"generator"), dict):
19577 grid_cfg[
"generator"].pop(
"output_file",
None)
19578 grid_cfg[
"generator"].pop(
"vts_file",
None)
19579 grid_cfg[
"generator"].pop(
"stats_file",
None)
19582 "provider": grid_mode
or "missing",
19583 "execution":
"runtime-c" if grid_mode ==
"programmatic_c" else "precomputable",
19584 "dependencies": [],
19587 providers.append(grid_provider)
19589 initial = copy.deepcopy(
19590 ((case_cfg.get(
"properties")
or {}).get(
"initial_conditions")
or {})
19592 initial_mode = str(initial.get(
"mode",
"generated")).strip().lower()
19593 initial_generator = str(initial.get(
"generator",
"constant")).strip().lower()
19594 generated_python = (
19595 initial_mode ==
"generated"
19596 and initial_generator
in _PYTHON_INITIAL_CONDITION_PROVIDERS
19598 initial_provider = {
19599 "kind":
"initial-condition",
19600 "provider":
"file" if initial_mode ==
"file" else initial_generator,
19601 "execution":
"precomputable" if initial_mode ==
"file" or generated_python
else "runtime-c",
19602 "dependencies": [
"grid"]
if generated_python
else [],
19605 providers.append(initial_provider)
19608 raw_blocks = case_cfg.get(
"boundary_conditions")
or []
19609 if raw_blocks
and isinstance(raw_blocks[0], dict):
19610 raw_blocks = [raw_blocks]
19611 for block_index, block
in enumerate(raw_blocks):
19612 for entry
in block
or []:
19613 if not isinstance(entry, dict)
or str(entry.get(
"handler",
"")).strip().lower() !=
"prescribed_flow":
19615 source = copy.deepcopy(((entry.get(
"params")
or {}).get(
"source")
or {}))
19616 inlet_specs.append({
"block": block_index,
"face": entry.get(
"face"),
"source": source})
19618 field_slice = any((item.get(
"source")
or {}).get(
"type") ==
"field_slice" for item
in inlet_specs)
19620 "kind":
"inlet-profiles",
19621 "provider":
"prescribed-flow",
19622 "execution":
"precomputable",
19623 "dependencies": [
"grid"]
if field_slice
or grid_mode
in _FILE_BACKED_GRID_VALUES
else [],
19624 "spec": inlet_specs,
19633 scaling = (case_cfg.get(
"properties")
or {}).get(
"scaling")
or {}
19634 fluid = (case_cfg.get(
"properties")
or {}).get(
"fluid")
or {}
19635 domain_blocks = (case_cfg.get(
"models")
or {}).get(
"domain", {}).get(
"blocks", 1)
19638 "length_ref": scaling.get(
"length_ref"),
19639 "blocks": domain_blocks,
19642 "initial-condition": {
19643 "length_ref": scaling.get(
"length_ref"),
19644 "velocity_ref": scaling.get(
"velocity_ref"),
19645 "density": fluid.get(
"density"),
19646 "viscosity": fluid.get(
"viscosity"),
19647 "boundary_conditions": case_cfg.get(
"boundary_conditions"),
19652 "inlet-profiles": {
19653 "length_ref": scaling.get(
"length_ref"),
19654 "velocity_ref": scaling.get(
"velocity_ref"),
19655 "blocks": domain_blocks,
19656 "programmatic_settings": (
19657 grid_cfg.get(
"programmatic_settings")
if grid_mode ==
"programmatic_c" else None
19664 by_kind = {provider[
"kind"]: provider
for provider
in providers}
19665 resolved_order = sorted(
19666 providers, key=
lambda provider: len(provider.get(
"dependencies")
or [])
19668 for provider
in resolved_order:
19669 provider[
"build_context"] = build_contexts.get(provider[
"kind"], {})
19671 provider[
"software"] = {
19672 "release_version": PICURV_RELEASE_VERSION,
19676 "kind": provider[
"kind"],
19677 "provider": provider[
"provider"],
19678 "spec": provider[
"spec"],
19679 "build_context": provider[
"build_context"],
19680 "source_files": provider[
"source_files"],
19681 "software": provider[
"software"],
19683 name: by_kind[name].get(
"spec_sha256")
19684 for name
in (provider.get(
"dependencies")
or [])
19690 "providers": providers,
19696 @brief Resolve requested asset kinds plus dependency closure.
19697 @param[in] graph Provider graph returned by build_case_asset_graph.
19698 @param[in] requested Optional iterable of requested kind names.
19699 @param[in] precomputable_only Exclude runtime-C providers for normal-run staging.
19700 @return Ordered provider list.
19702 by_kind = {item[
"kind"]: item
for item
in graph[
"providers"]}
19703 requested_set = set(requested
or by_kind)
19704 unknown = requested_set - set(by_kind)
19707 f
"No configured provider exists for asset kind(s): {sorted(unknown)}. "
19708 f
"Configured kinds: {sorted(by_kind)}."
19714 @brief Add one requested asset kind and its dependency closure.
19715 @param[in] kind Asset kind to include.
19718 if kind
in closure:
19721 for dependency
in by_kind[kind].get(
"dependencies", []):
19722 if dependency
in by_kind:
19725 for kind
in requested_set:
19727 selected = [item
for item
in graph[
"providers"]
if item[
"kind"]
in closure]
19728 if precomputable_only:
19729 selected = [item
for item
in selected
if item[
"execution"] ==
"precomputable"]
19735 @brief Enumerate canonical files belonging to one asset kind in a build tree.
19736 @param[in] build_root Temporary run-like build root.
19737 @param[in] kind Asset kind.
19738 @return Absolute payload file paths.
19741 "grid": [
"inputs/grid",
"output/analysis/metrics",
"output/visualization/precompute"],
19742 "initial-condition": [
"inputs/initial_condition",
"output/analysis/spectra",
"output/analysis/metrics"],
19743 "inlet-profiles": [
"inputs/inlet_profiles"],
19746 for relative
in roots:
19747 root = Path(build_root, *relative.split(
"/"))
19749 result.extend(str(path)
for path
in sorted(root.rglob(
"*"))
if path.is_file())
19750 intermediate_names = {
19751 "grid": {
"grid.generated.picgrid",
"grid.converted.picgrid"},
19752 "initial-condition": {
"initial_condition.generated.dat"},
19753 "inlet-profiles": set(),
19756 path
for path
in dict.fromkeys(result)
19757 if os.path.basename(path)
not in intermediate_names
19762 selected: list) -> dict:
19764 @brief Execute existing generators into one isolated run-like build tree.
19765 @param[in] build_root Temporary output root.
19766 @param[in] case_cfg Parsed case config.
19767 @param[in] case_path Owning case path.
19768 @param[in] selected Ordered selected providers.
19769 @return Asset-kind to generated file list.
19772 selected_kinds = {item[
"kind"]
for item
in selected}
19775 def fingerprint(kind):
19777 @brief Snapshot current payload checksums for one asset kind.
19778 @param[in] kind Asset kind to inspect.
19779 @return Absolute-path to checksum mapping.
19786 def record_changes(kind, before):
19788 @brief Record files created or changed by one provider stage.
19789 @param[in] kind Asset kind whose build just completed.
19790 @param[in] before Pre-build checksum mapping.
19793 after = fingerprint(kind)
19795 path
for path, digest
in after.items()
19796 if before.get(path) != digest
19799 grid_cfg = case_cfg.get(
"grid", {})
or {}
19800 expected_nblk = int((case_cfg.get(
"models", {})
or {}).get(
"domain", {}).get(
"blocks", 1))
19801 staged_grid = os.path.join(build_root,
"inputs",
"grid",
"grid.run")
19802 if "grid" in selected_kinds:
19803 before = fingerprint(
"grid")
19804 if grid_cfg.get(
"mode")
in _FILE_BACKED_GRID_VALUES:
19807 if grid_cfg.get(
"mode") ==
"grid_gen":
19810 generated, staged_grid, grid_length_scale, expected_nblk=expected_nblk
19812 elif grid_cfg.get(
"mode") ==
"file":
19814 grid_cfg.get(
"source_file"), os.path.dirname(os.path.abspath(case_path))
19817 source, staged_grid, grid_length_scale, expected_nblk=expected_nblk
19819 record_changes(
"grid", before)
19821 if "inlet-profiles" in selected_kinds:
19822 before = fingerprint(
"inlet-profiles")
19824 build_root,
"asset-build", case_cfg,
19825 {
"Case": case_path},
19827 record_changes(
"inlet-profiles", before)
19829 if "initial-condition" in selected_kinds:
19830 before = fingerprint(
"initial-condition")
19833 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
19835 scales=fluid_scaling,
19837 "kinematic_viscosity": fluid_scaling[
"nondimensional_kinematic_viscosity"]
19841 and not os.path.isfile(staged_grid)):
19851 grid_cfg.get(
"programmatic_settings", {}), staged_grid,
19855 record_changes(
"initial-condition", before)
19862ASSET_PREVIEW_NODE_LIMIT = 2_000_000
19867 @brief Read a canonical PICGRID and summarize what a user would want to check.
19868 @param[in] path Staged PICGRID file.
19869 @return Mapping of block dimensions, bounds, and spacing extremes.
19872 bounds = [[float(
"inf")] * 3, [float(
"-inf")] * 3]
19874 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
19877 for _
in range(2 + len(dims)):
19878 next(iterator,
None)
19879 for _lineno, line
in iterator:
19880 parts = line.split()
19881 if len(parts) != 3:
19884 point = [float(value)
for value
in parts]
19888 for axis
in range(3):
19889 bounds[0][axis] = min(bounds[0][axis], point[axis])
19890 bounds[1][axis] = max(bounds[1][axis], point[axis])
19891 extent = [bounds[1][axis] - bounds[0][axis]
for axis
in range(3)]
if nodes
else [0.0, 0.0, 0.0]
19893 "blocks": len(dims),
19894 "dimensions": [
list(item)
for item
in dims],
19895 "total_nodes": nodes,
19896 "bounds_min": bounds[0]
if nodes
else None,
19897 "bounds_max": bounds[1]
if nodes
else None,
19904 @brief Write a single-block ASCII VTS preview of a staged PICGRID.
19906 @details Only the first block is written: the preview exists so a user can confirm
19907 the shape they configured, not to reproduce the solver's view of a
19908 multi-block domain.
19909 @param[in] picgrid_path Staged PICGRID file.
19910 @param[in] destination Preview path to write.
19911 @param[in] dims Per-block dimension triples.
19912 @return True when a preview was written.
19916 im, jm, km = (int(value)
for value
in dims[0])
19917 if im * jm * km > ASSET_PREVIEW_NODE_LIMIT:
19920 with open(picgrid_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
19922 for _
in range(2 + len(dims)):
19923 next(iterator,
None)
19924 for _lineno, line
in iterator:
19925 parts = line.split()
19926 if len(parts) == 3:
19927 coordinates.append(line)
19928 if len(coordinates) >= im * jm * km:
19930 if len(coordinates) < im * jm * km:
19932 os.makedirs(os.path.dirname(destination), exist_ok=
True)
19933 with open(destination,
"w", encoding=
"utf-8")
as out:
19934 extent = f
"0 {im - 1} 0 {jm - 1} 0 {km - 1}"
19935 out.write(
'<?xml version="1.0"?>\n')
19936 out.write(
'<VTKFile type="StructuredGrid" version="1.0" byte_order="LittleEndian">\n')
19937 out.write(f
' <StructuredGrid WholeExtent="{extent}">\n')
19938 out.write(f
' <Piece Extent="{extent}">\n')
19939 out.write(
' <Points>\n')
19940 out.write(
' <DataArray type="Float64" NumberOfComponents="3" format="ascii">\n')
19941 for row
in coordinates:
19942 out.write(f
" {row}\n")
19943 out.write(
' </DataArray>\n </Points>\n')
19944 out.write(
' </Piece>\n </StructuredGrid>\n</VTKFile>\n')
19949 payload_files: list) -> dict:
19951 @brief Produce the inspection material published beside an asset's payload.
19953 @details Precompute exists so a user can look at a grid, field, or profile and
19954 change it before committing a solve. An asset that carries only opaque
19955 solver input cannot serve that purpose, so every published object gets a
19956 validation record and, where it is affordable, a preview.
19957 @param[in] kind Asset kind.
19958 @param[in] build_root Temporary build tree.
19959 @param[in] provider Provider metadata for the asset.
19960 @param[in] payload_files Absolute payload paths produced for this asset.
19961 @return Mapping of published inspection filename to its absolute source path.
19963 inspection_dir = os.path.join(build_root,
".inspection")
19964 os.makedirs(inspection_dir, exist_ok=
True)
19967 "asset_kind": kind,
19968 "provider": provider[
"provider"],
19969 "generated_at": datetime.now().astimezone().isoformat(),
19970 "files": [os.path.relpath(path, build_root).replace(os.sep,
"/")
for path
in payload_files],
19974 staged = os.path.join(build_root,
"inputs",
"grid",
"grid.run")
19975 if os.path.isfile(staged):
19978 validation[
"geometry"] = geometry
19979 preview = os.path.join(inspection_dir,
"preview.vts")
19981 published[
"preview.vts"] = preview
19983 validation[
"preview"] = (
19984 "skipped: the first block exceeds "
19985 f
"{ASSET_PREVIEW_NODE_LIMIT} nodes"
19987 except (OSError, ValueError, StopIteration)
as exc:
19988 validation[
"geometry_error"] = str(exc)
19989 generator_preview = os.path.join(
19990 build_root,
"output",
"visualization",
"precompute",
"grid.vts"
19992 if os.path.isfile(generator_preview):
19993 published[
"preview.vts"] = generator_preview
19994 validation.pop(
"preview",
None)
19995 info = os.path.join(build_root,
"output",
"analysis",
"metrics",
"grid.info")
19996 if os.path.isfile(info):
19997 published[
"grid.info"] = info
19999 elif kind ==
"initial-condition":
20002 for published_name, suffix
in (
20003 (
"summary.json",
"_summary.json"), (
"spectrum.csv",
"_spectrum.csv"),
20005 for candidate
in payload_files:
20006 base = os.path.basename(candidate)
20007 if base == published_name
or base.endswith(suffix):
20008 published[published_name] = candidate
20010 for candidate
in payload_files:
20011 base = os.path.basename(candidate)
20012 if base.startswith((
"initial_condition_plane_spectrum_",
"initial_condition_line_spectrum_"))
and base.endswith((
".csv",
".json")):
20013 published[base] = candidate
20014 validation[
"fields"] = sorted(
20015 os.path.basename(path)
for path
in payload_files
if path.endswith(
".dat")
20017 summary_source = published.get(
"summary.json")
20020 with open(summary_source,
"r", encoding=
"utf-8")
as stream:
20021 validation[
"summary"] = json.load(stream)
20022 except (OSError, ValueError):
20025 elif kind ==
"inlet-profiles":
20026 for candidate
in payload_files:
20027 if os.path.basename(candidate) ==
"profile.info":
20028 published[
"profile.info"] = candidate
20029 validation[
"profiles"] = sorted(
20030 os.path.basename(path)
for path
in payload_files
20031 if path.endswith(
".picslice")
20034 validation_path = os.path.join(inspection_dir,
"validation.json")
20036 published[
"validation.json"] = validation_path
20041 payload_files: list) -> dict:
20043 @brief Publish one immutable content-addressed asset object atomically.
20044 @param[in] workspace_root Owning workspace.
20045 @param[in] build_root Temporary build root.
20046 @param[in] provider Provider metadata.
20047 @param[in] payload_files Files produced for the provider.
20048 @return Asset reference written to the asset set.
20050 file_inventory = []
20051 for path
in payload_files:
20052 relative = os.path.relpath(path, build_root).replace(os.sep,
"/")
20053 file_inventory.append({
20055 "bytes": os.path.getsize(path),
20059 "provider_spec_sha256": provider[
"spec_sha256"],
20060 "files": file_inventory,
20065 provider[
"kind"], build_root, provider, payload_files
20067 kind_dir = ASSET_KIND_DIRECTORIES[provider[
"kind"]]
20068 object_root = os.path.join(workspace_root,
"assets",
"objects", kind_dir, asset_id)
20069 if not os.path.isdir(object_root):
20070 parent = os.path.dirname(object_root)
20071 os.makedirs(parent, exist_ok=
True)
20072 temporary = tempfile.mkdtemp(prefix=f
".{asset_id[:12]}-", dir=parent)
20074 for item, source
in zip(file_inventory, payload_files):
20075 destination = os.path.join(temporary,
"payload", *item[
"path"].split(
"/"))
20076 os.makedirs(os.path.dirname(destination), exist_ok=
True)
20077 shutil.copy2(source, destination)
20078 for name, source
in sorted(inspection.items()):
20079 shutil.copy2(source, os.path.join(temporary, name))
20081 "schema_version": ASSET_MANIFEST_SCHEMA_VERSION,
20082 "asset_id": asset_id,
20083 "kind": provider[
"kind"],
20084 "provider": provider[
"provider"],
20085 "provider_execution": provider[
"execution"],
20086 "provider_spec_sha256": provider[
"spec_sha256"],
20087 "provider_spec": provider[
"spec"],
20088 "source_files": provider[
"source_files"],
20089 "software": provider[
"software"],
20090 "created_at": datetime.now().astimezone().isoformat(),
20091 "files": file_inventory,
20092 "inspection": sorted(inspection),
20096 os.replace(temporary, object_root)
20097 except OSError
as exc:
20098 if exc.errno != errno.ENOTEMPTY
and not os.path.isdir(object_root):
20101 if os.path.isdir(temporary):
20102 shutil.rmtree(temporary, ignore_errors=
True)
20104 "asset_id": asset_id,
20105 "kind": provider[
"kind"],
20106 "provider": provider[
"provider"],
20107 "provider_spec_sha256": provider[
"spec_sha256"],
20108 "object": os.path.relpath(object_root, workspace_root).replace(os.sep,
"/"),
20109 "files": file_inventory,
20110 "inspection": sorted(inspection),
20116 @brief Return a collision-free, readable mutable asset-set name.
20117 @param[in] workspace_root Owning initialized workspace.
20118 @param[in] case_path Source case configuration path.
20119 @return Stable asset-set filename stem.
20121 stem = re.sub(
r"[^A-Za-z0-9_.-]+",
"-", Path(case_path).stem).strip(
"-")
or "case"
20123 digest = hashlib.sha256(str(relative).encode(
"utf-8")).hexdigest()[:10]
20124 return f
"{stem}-{digest}"
20128 references: dict) -> str:
20130 @brief Atomically update the named asset set and workspace asset catalog.
20131 @param[in] workspace_root Owning workspace.
20132 @param[in] case_path Source case YAML.
20133 @param[in] graph Complete provider graph.
20134 @param[in] references Newly published or reused asset references.
20135 @return Asset-set YAML path.
20138 set_path = os.path.join(workspace_root,
"assets",
"sets", f
"{name}.yml")
20139 existing =
read_yaml_file(set_path)
if os.path.isfile(set_path)
else {}
20140 assets = dict(existing.get(
"assets")
or {})
20141 assets.update(references)
20143 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20145 "case": os.path.relpath(case_path, workspace_root).replace(os.sep,
"/"),
20146 "case_sha256": graph[
"case_sha256"],
20147 "updated_at": datetime.now().astimezone().isoformat(),
20149 "runtime_providers": {
20151 "provider": item[
"provider"],
20152 "provider_spec_sha256": item[
"spec_sha256"],
20154 for item
in graph[
"providers"]
if item[
"execution"] ==
"runtime-c"
20158 catalog_path = os.path.join(workspace_root,
"assets",
"catalog.yml")
20159 catalog =
read_yaml_file(catalog_path)
if os.path.isfile(catalog_path)
else {
20160 "schema_version": 1,
"objects": {}
20162 objects = catalog.setdefault(
"objects", {})
20163 for reference
in references.values():
20164 objects[reference[
"asset_id"]] = {
20165 "kind": reference[
"kind"],
20166 "provider": reference[
"provider"],
20167 "object": reference[
"object"],
20174 requested=
None, precomputable_only: bool =
False) -> dict:
20176 @brief Build and publish a selected deterministic asset dependency closure.
20177 @param[in] workspace_root Owning workspace.
20178 @param[in] case_cfg Parsed case configuration.
20179 @param[in] case_path Source case path.
20180 @param[in] requested Requested asset kinds, or all configured providers.
20181 @param[in] precomputable_only Drop runtime-C dependencies from the closure instead of
20182 refusing. For internal run-staging only: a run legitimately
20183 depends on a runtime-C grid that its own solver builds
20184 moments later, so its presence in the dependency closure is
20185 expected there, not an error. The standalone `picurv
20186 precompute` command leaves this False, since nothing will
20187 ever build a runtime-C provider in that context.
20188 @return Provider graph, selected providers, references, and asset-set path.
20192 selected =
_asset_selection(graph, requested, precomputable_only=precomputable_only)
20193 if not precomputable_only:
20194 runtime = [item
for item
in selected
if item[
"execution"] ==
"runtime-c"]
20196 details =
", ".join(f
"{item['kind']}={item['provider']}" for item
in runtime)
20198 "Precompute is atomic and cannot execute simulator-runtime providers. "
20199 f
"Selected dependency graph requires C generation: {details}. "
20200 "Use --only to select an independent precomputable subset, or run the case; "
20201 "the simulator will report each runtime provider before generation."
20204 raise ValueError(
"The selected case has no precomputable providers.")
20205 staging_parent = os.path.join(workspace_root,
"assets")
20206 os.makedirs(staging_parent, exist_ok=
True)
20207 build_root = tempfile.mkdtemp(prefix=
".precompute-", dir=staging_parent)
20211 for provider
in selected:
20212 files = payloads.get(provider[
"kind"], [])
20215 f
"Provider {provider['kind']}={provider['provider']} produced no files."
20218 workspace_root, build_root, provider, files
20221 shutil.rmtree(build_root, ignore_errors=
True)
20223 return {
"graph": graph,
"selected": selected,
"assets": references,
"set_path": set_path}
20228 @brief Return the mutable asset-set pointer associated with a case config name.
20229 @param[in] workspace_root Owning workspace.
20230 @param[in] case_path Source case path.
20231 @return Absolute asset-set YAML path.
20234 return os.path.join(workspace_root,
"assets",
"sets", f
"{name}.yml")
20239 @brief Find a published object built from exactly this provider's inputs.
20240 @details Asset sets are named after case files, so they miss an object another case
20241 file already built from identical inputs. The provider identity is what the
20242 object was built from, so any published object carrying it is reusable; the
20243 lowest asset id is taken so repeated lookups agree. Payload checksums are
20244 verified when the object is materialized, not here.
20245 @param[in] workspace_root Owning workspace.
20246 @param[in] provider Provider entry from build_case_asset_graph().
20247 @return Asset reference in the shape _publish_asset_object() returns, or None.
20249 kind_dir = ASSET_KIND_DIRECTORIES.get(provider[
"kind"])
20252 objects_root = os.path.join(workspace_root,
"assets",
"objects", kind_dir)
20253 if not os.path.isdir(objects_root):
20255 for asset_id
in sorted(os.listdir(objects_root)):
20256 object_root = os.path.join(objects_root, asset_id)
20258 if not (isinstance(manifest, dict)
20259 and manifest.get(
"schema_version") == ASSET_MANIFEST_SCHEMA_VERSION
20260 and manifest.get(
"asset_id") == asset_id
20261 and manifest.get(
"kind") == provider[
"kind"]
20262 and manifest.get(
"provider_spec_sha256") == provider[
"spec_sha256"]):
20265 "asset_id": asset_id,
20266 "kind": provider[
"kind"],
20267 "provider": manifest.get(
"provider", provider[
"provider"]),
20268 "provider_spec_sha256": provider[
"spec_sha256"],
20269 "object": os.path.relpath(object_root, workspace_root).replace(os.sep,
"/"),
20270 "files": manifest.get(
"files", []),
20271 "inspection": manifest.get(
"inspection", []),
20278 @brief Plan reuse/build/runtime actions for all configured run providers.
20279 @param[in] case_cfg Parsed case configuration.
20280 @param[in] case_path Source case path.
20281 @param[in] skip_kinds Kinds the run will not read; planned as `unused`, never built.
20282 @return Workspace, graph, set path, and per-provider actions.
20290 if os.path.isfile(set_path):
20292 refs = asset_set.get(
"assets", {})
if isinstance(asset_set, dict)
else {}
20294 for provider
in graph[
"providers"]:
20295 reference = refs.get(provider[
"kind"])
if isinstance(refs, dict)
else None
20297 isinstance(reference, dict)
20298 and reference.get(
"provider_spec_sha256") == provider[
"spec_sha256"]
20300 and os.path.isfile(os.path.join(
20301 workspace_root, reference.get(
"object",
""),
"asset.json"
20305 if provider[
"kind"]
in skip_kinds:
20306 actions.append({**provider,
"action":
"unused",
"reference":
None,
"adopted":
False})
20308 if not matches
and workspace_root
and provider[
"execution"] !=
"runtime-c":
20313 matches = adopted = reference
is not None
20314 if provider[
"execution"] ==
"runtime-c":
20315 action =
"runtime-c"
20320 actions.append({**provider,
"action": action,
"reference": reference
if matches
else None,
20321 "adopted": adopted})
20323 "workspace_root": workspace_root,
20325 "asset_set_path": set_path,
20326 "actions": actions,
20332 @brief Expose one immutable shared-asset file through reflink, hardlink, or copy.
20333 @param[in] source Immutable asset payload file.
20334 @param[in] destination Run-local destination.
20335 @return Materialization mode used.
20337 os.makedirs(os.path.dirname(destination), exist_ok=
True)
20338 temporary = f
"{destination}.tmp.{os.getpid()}"
20340 cp = shutil.which(
"cp")
20342 result = subprocess.run(
20343 [cp,
"--reflink=always",
"--preserve=mode,timestamps", source, temporary],
20344 text=
True, capture_output=
True, check=
False,
20346 if result.returncode == 0:
20347 os.replace(temporary, destination)
20349 if os.path.lexists(temporary):
20350 os.remove(temporary)
20352 os.link(source, temporary)
20353 os.replace(temporary, destination)
20356 if os.path.lexists(temporary):
20357 os.remove(temporary)
20358 shutil.copy2(source, temporary)
20359 os.replace(temporary, destination)
20362 if os.path.lexists(temporary):
20363 os.remove(temporary)
20367 require_precomputed: bool =
False,
20368 fetch_missing: bool =
False, skip_kinds=()) -> dict:
20370 @brief Resolve/build workspace assets and write the exact run input lock.
20371 @param[in] run_dir Run receiving immutable input exposures.
20372 @param[in] case_cfg Parsed case config.
20373 @param[in] case_path Source case path.
20374 @param[in] require_precomputed Refuse any missing deterministic asset.
20375 @param[in] fetch_missing Request remote fetch before local build.
20376 @param[in] skip_kinds Kinds the run will not read (see unused_asset_kinds()).
20377 @return Written lock mapping, or a standalone-provider summary outside a workspace.
20380 workspace_root = plan[
"workspace_root"]
20381 if not workspace_root:
20383 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20386 "runtime_providers": {
20387 item[
"kind"]: item[
"provider"]
for item
in plan[
"actions"]
20388 if item[
"execution"] ==
"runtime-c"
20392 missing = [item
for item
in plan[
"actions"]
if item[
"action"] ==
"build"]
20393 if missing
and fetch_missing:
20397 from .storage
import restore_missing_workspace_assets
20398 except ImportError:
20399 restore_missing_workspace_assets = getattr(
20400 _storage_module,
"restore_missing_workspace_assets",
None
20402 restored = restore_missing_workspace_assets(
20403 workspace_root, [item[
"spec_sha256"]
for item
in missing]
20404 )
if restore_missing_workspace_assets
else {}
20407 item[
"kind"]: restored[item[
"spec_sha256"]]
20408 for item
in missing
if item[
"spec_sha256"]
in restored
20412 workspace_root, case_path, plan[
"graph"], recovered_refs
20415 missing = [item
for item
in plan[
"actions"]
if item[
"action"] ==
"build"]
20416 if missing
and require_precomputed:
20417 names =
", ".join(f
"{item['kind']}={item['provider']}" for item
in missing)
20419 f
"Required precomputed asset(s) are missing or stale: {names}. "
20420 f
"Run 'picurv precompute --case {case_path} --only "
20421 +
",".join(item[
"kind"]
for item
in missing) +
"'."
20425 workspace_root, case_cfg, case_path,
20426 requested=[item[
"kind"]
for item
in missing],
20427 precomputable_only=
True,
20430 still_missing = [item
for item
in plan[
"actions"]
if item[
"action"] ==
"build"]
20433 "Asset generation completed without satisfying: "
20434 +
", ".join(item[
"kind"]
for item
in still_missing)
20438 item[
"kind"]: item[
"reference"]
for item
in plan[
"actions"]
if item.get(
"adopted")
20445 for item
in plan[
"actions"]:
20446 reference = item.get(
"reference")
20447 if item[
"action"] !=
"reuse" or not isinstance(reference, dict):
20449 object_root = os.path.join(workspace_root, reference[
"object"])
20451 if not isinstance(manifest, dict)
or manifest.get(
"asset_id") != reference.get(
"asset_id"):
20452 raise ValueError(f
"Asset object is missing or invalid: {object_root}")
20454 for file_info
in manifest.get(
"files", []):
20455 relative = file_info[
"path"]
20456 source = os.path.join(object_root,
"payload", *relative.split(
"/"))
20458 raise ValueError(f
"Asset payload checksum mismatch: {source}")
20459 destination = os.path.join(run_dir, *relative.split(
"/"))
20461 exposed.append({
"path": relative,
"mode": mode,
"sha256": file_info[
"sha256"]})
20462 assets[item[
"kind"]] = {**reference,
"exposed": exposed}
20463 print(f
"[INFO] Asset {item['kind']}: reuse {reference['asset_id']}")
20464 runtime_providers = {
20466 "provider": item[
"provider"],
20467 "provider_spec_sha256": item[
"spec_sha256"],
20469 for item
in plan[
"actions"]
20470 if item[
"execution"] ==
"runtime-c" and item[
"action"] !=
"unused"
20472 for kind, provider
in runtime_providers.items():
20473 print(f
"[INFO] Runtime provider {kind}: {provider['provider']} (generated by simulator)")
20475 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20476 "workspace": workspace_root,
20477 "case_sha256": plan[
"graph"][
"case_sha256"],
20478 "created_at": datetime.now().astimezone().isoformat(),
20480 "runtime_providers": runtime_providers,
20482 write_yaml_file(os.path.join(run_dir,
"inputs",
"assets.lock.yml"), lock)
20489 @brief Resolve, preflight, and atomically publish reusable workspace assets.
20490 @param[in] args Parsed precompute command arguments.
20492 case_path = os.path.abspath(args.case)
20494 if not workspace_root:
20496 "Precompute requires an initialized PICurv workspace. Run 'picurv init ...', "
20497 "then use its config/case.yml."
20500 raw_only = getattr(args,
"only",
None)
20502 if raw_only
and str(raw_only).strip().lower() !=
"all":
20503 requested = [token.strip()
for token
in str(raw_only).split(
",")
if token.strip()]
20506 print(f
"[INFO] Workspace : {workspace_root}")
20507 print(f
"[INFO] Case : {os.path.relpath(case_path, workspace_root)}")
20508 print(
"[INFO] Asset dependency plan:")
20509 for provider
in selected:
20510 dependencies =
",".join(provider.get(
"dependencies", []))
or "none"
20512 f
" - {provider['kind']}: {provider['provider']} "
20513 f
"[{provider['execution']}], dependencies={dependencies}"
20516 for kind, reference
in sorted(result[
"assets"].items()):
20517 print(f
"[SUCCESS] {kind}: {reference['asset_id']} ({reference['object']})")
20518 print(f
"[SUCCESS] Asset set: {os.path.relpath(result['set_path'], workspace_root)}")
20522 @brief Main orchestrator for the 'run' command (local and Slurm modes).
20523 @param[in] args Command-line style argument list supplied to the function.
20525 if getattr(args,
"dry_run",
False):
20528 if plan.get(
"blocking"):
20534 output_dir_abs =
None
20538 statistics_output_paths = []
20539 workflow_start = time.time()
20540 stages_completed = []
20542 submission_meta = {
"launch_mode":
"local",
"no_submit": bool(args.no_submit),
"stages": {}}
20544 cluster_mode = bool(getattr(args,
"cluster",
None))
20546 cluster_path =
None
20547 solver_num_procs_effective = args.num_procs
20548 post_num_procs_effective = args.num_procs
20551 cluster_path = os.path.abspath(args.cluster)
20554 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
20555 if args.scheduler
and args.scheduler.lower() != scheduler_type:
20557 ERROR_CODE_CFG_INCONSISTENT_COMBO, key=
"scheduler.type", file_path=cluster_path,
20558 message=f
"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
20559 hint=
"Omit --scheduler, or give the scheduler cluster.yml names.",
20562 if scheduler_type !=
"slurm":
20564 ERROR_CODE_CFG_INVALID_VALUE, key=
"scheduler.type", file_path=cluster_path,
20565 message=f
"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported.",
20566 hint=
"Set scheduler.type: slurm in cluster.yml.",
20570 if (args.solve
or args.post_process)
and args.num_procs
not in (1, cluster_tasks):
20572 "In cluster mode, --num-procs must be "
20573 f
"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
20574 hint=
"Omit -n to use the task count cluster.yml sets.",
20577 solver_num_procs_effective = cluster_tasks
20578 if args.post_process:
20579 post_num_procs_effective = cluster_tasks
20580 submission_meta[
"launch_mode"] =
"slurm"
20581 submission_meta[
"cluster_config"] = cluster_path
20582 submission_meta[
"no_submit"] = bool(args.no_submit)
20583 if args.solve
and args.post_process:
20585 f
"[INFO] Cluster mode enabled (Slurm). Solver and post stages use "
20586 f
"{solver_num_procs_effective} MPI tasks from cluster.yml."
20589 print(f
"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks from cluster.yml.")
20591 print(f
"[INFO] Cluster mode enabled (Slurm). Post stage uses {post_num_procs_effective} MPI tasks from cluster.yml.")
20595 if getattr(args,
'restart_from',
None):
20596 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
20597 if getattr(args,
'continue_run',
False):
20598 print(
"[WARNING] --continue only extends a solve in place and is ignored here. "
20599 "Post-processing always skips steps whose output is up to date; drop "
20600 "--continue from post-only commands.", file=sys.stderr)
20604 case_input_path = os.path.abspath(args.case)
20605 workspace_root =
find_workspace_root(case_input_path, args.solver, args.monitor, os.getcwd())
20611 'case':
read_yaml_file(args.case),
'case_path': case_input_path,
20612 'solver':
read_yaml_file(args.solver),
'solver_path': os.path.abspath(args.solver),
20613 'monitor':
read_yaml_file(args.monitor),
'monitor_path': os.path.abspath(args.monitor),
20614 'walltime_guard_policy': walltime_guard_policy,
20615 'statistics_state': getattr(args,
"statistics_state",
None)
or "reset",
20616 'decomposition': getattr(args,
"decomposition",
None),
20617 'cluster_cfg': cluster_cfg
if cluster_mode
else None,
20620 print(
"\n[INFO] Validating configuration files...")
20622 configs[
'case'], configs[
'solver'], configs[
'monitor'],
20623 args.case, args.solver, args.monitor,
20624 restart_source_given=bool(getattr(args,
'restart_from',
None)
or getattr(args,
'continue_run',
False)),
20626 print(
"[SUCCESS] All configuration files passed validation.\n")
20628 continue_mode = getattr(args,
'continue_run',
False)
20631 if not args.run_dir:
20633 run_dir = os.path.abspath(args.run_dir)
20634 if not os.path.isdir(run_dir):
20636 ERROR_CODE_CFG_FILE_NOT_FOUND,
20639 message=
"Specified run directory not found.",
20643 run_id = os.path.basename(run_dir)
20647 runs_root, configs[
"case"], configs[
"case_path"]
20649 run_dir = os.path.join(runs_root, run_id)
20651 if workspace_root
and os.path.commonpath([os.path.abspath(run_dir), workspace_root]) != workspace_root:
20652 fail_cli_usage(
"A workspace run directory must remain below the owning workspace.")
20653 if not continue_mode
and os.path.exists(run_dir):
20654 raise ValueError(f
"Generated run directory already exists: {run_dir}")
20662 if continue_mode
else None)
20665 args, configs[
"case"], configs[
"solver"], configs[
"monitor"], run_dir
20668 resolved_restart_source_dir)
20669 except ValueError
as e:
20676 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20678 file_path=args.case,
20680 hint=_STORAGE_RESTORE_HINT
if isinstance(e.__cause__, StorageError)
else _RESTART_HINT,
20684 config_dir = os.path.join(run_dir,
"config")
20686 print(f
"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
20688 print(f
"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
20691 source_run_dir = run_dir
if is_continue
else (
20692 os.path.join(workspace_root
or "", run_lineage[
"parent_path"])
if run_lineage
else None)
20693 source_grid_inputs =
None
20694 if source_run_dir
and os.path.isdir(source_run_dir):
20696 if source_case_path
and os.path.isfile(source_case_path):
20702 "solver": args.solver,
20703 "monitor": args.monitor,
20704 "cluster": cluster_path
if cluster_mode
else None,
20706 continuation=continue_mode,
20714 except ValueError
as exc:
20716 print(f
"[FATAL] {exc}", file=sys.stderr)
20722 configs[
"case_path"],
20723 require_precomputed=bool(getattr(args,
"require_precomputed",
False)),
20724 fetch_missing=bool(getattr(args,
"fetch_missing",
False)),
20727 except ValueError
as exc:
20729 print(f
"[FATAL] {exc}", file=sys.stderr)
20735 os.path.join(run_dir,
"manifest.json"),
20739 workspace_root=workspace_root,
20740 launch_mode=
"slurm" if cluster_mode
else "local",
20741 num_procs=solver_num_procs_effective,
20742 post_num_procs=post_num_procs_effective,
20743 stages_requested={
"solve":
True,
"post_process": bool(args.post_process)},
20749 asset_lock=asset_lock,
20750 lineage=run_lineage,
20754 print(
"\n" +
"="*25 +
" SOLVER STAGE " +
"="*25)
20755 source_files = {
'Case': args.case,
'Solver': args.solver,
'Monitor': args.monitor}
20756 generated_config_dir = (
20757 config_dir
if active_config[
"revision"] ==
"initial"
20758 else os.path.join(config_dir,
"history", active_config[
"revision"])
20761 run_dir, run_id, configs[
'monitor'], source_files,
20762 config_dir=generated_config_dir,
20764 if resolved_restart_source_dir:
20765 print(f
"[INFO] Restart source: {resolved_restart_source_dir}")
20767 print(
"[INFO] Continue mode: logs will be appended, not overwritten.")
20772 solver_num_procs_effective,
20774 restart_source_dir=resolved_restart_source_dir,
20775 continue_mode=is_continue,
20776 config_dir=generated_config_dir,
20782 monitor_files.get(
"whitelist"),
20783 monitor_files.get(
"profile"),
20784 *glob.glob(os.path.join(generated_config_dir,
"bcs*.run")),
20793 if not had_pinned_executables
or getattr(args,
"pin_executables",
None)
is True:
20796 if source_grid_inputs
is not None:
20798 carries_statistics = is_continue
or str(configs.get(
"statistics_state",
"reset")).lower() ==
"carry"
20801 config_search_anchor=configs[
"case_path"])
20805 undone = f
"{os.path.relpath(run_dir)} is left as it was"
20808 undone =
"the branch run directory was removed"
20809 print(f
"[FATAL] This restart cannot continue its statistics windows: {refusal}\n"
20810 f
" Nothing was launched or submitted, and {undone}.", file=sys.stderr)
20813 scheduler_dir = os.path.join(run_dir,
"scheduler")
20814 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
20815 solver_log = os.path.join(scheduler_dir,
"solver_%j.out")
20816 solver_err = os.path.join(scheduler_dir,
"solver_%j.err")
20821 config_search_anchor=args.case,
20822 extra_search_anchors=[cluster_path],
20832 env_vars={
"LOG_LEVEL": configs[
'monitor'].get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
20837 submission_meta[
"stages"][
"solve"] = {
20838 "script": solver_script,
20839 "submitted":
False,
20840 "num_procs_effective": solver_num_procs_effective,
20843 print(f
"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
20844 if not args.no_submit:
20846 submission_meta[
"stages"][
"solve"].update(submit_info)
20847 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
20848 print(f
"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
20849 stages_completed.append(
'solve')
20854 solver_num_procs_effective,
20855 config_search_anchor=configs[
"case_path"],
20857 solver_log = os.path.join(
"scheduler", f
"{run_id}_solver.log")
20858 submission_meta[
"stages"][
"solve"] = {
20859 "command": command,
20861 "log_file": solver_log,
20862 "submitted":
False,
20863 "num_procs_effective": solver_num_procs_effective,
20867 print(f
"[SUCCESS] Staged local solve stage; run it with "
20868 f
"'picurv submit --run-dir {os.path.relpath(run_dir)}'.")
20871 with runtime_stage_lock(run_dir,
"solver"):
20873 except StorageError
as exc:
20874 print(f
"[FATAL] {exc}", file=sys.stderr)
20876 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
20877 submission_meta[
"stages"][
"solve"][
"executed"] =
True
20878 submission_meta[
"stages"][
"solve"][
"completed_at"] = datetime.now().isoformat()
20879 stages_completed.append(
'solve')
20882 if args.post_process:
20883 if not args.solve
and getattr(args,
"pin_executables",
None)
is not None:
20885 "--pin-executables and --no-pin-executables apply when the solve stage is staged.",
20886 hint=
"Post-processing launches the postprocessor the run is pinned to, or the "
20887 "installation's when the run is not pinned.",
20890 run_dir = os.path.abspath(args.run_dir)
20891 if not os.path.isdir(run_dir):
20893 message=
"Run directory not found.")
20895 print(f
"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
20898 run_id = read_artifact_identity(run_dir)[
"run_id"]
20900 print(
"\n" +
"="*20 +
" POST-PROCESSING STAGE " +
"="*20)
20901 config_dir = os.path.join(run_dir,
"config")
20904 if not all([case_path, monitor_path, solver_control_path]):
20905 print(f
"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
20907 print(
" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
20908 if not monitor_path:
20909 print(
" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
20910 if not solver_control_path:
20911 print(
" - No '.control' file found.", file=sys.stderr)
20914 print(f
"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
20915 print(f
"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
20921 print(
"[INFO] Validating post-processing configuration...")
20923 print(
"[SUCCESS] Post-processing configuration passed validation.\n")
20927 post_cfg[
"_picurv_paths"][
"visualization"],
20928 post_cfg[
"_picurv_paths"][
"statistics"],
20929 post_cfg[
"_picurv_paths"][
"spectra"],
20931 os.makedirs(os.path.join(run_dir, relative), exist_ok=
True)
20933 os.makedirs(os.path.dirname(archived_post_path), exist_ok=
True)
20934 if os.path.abspath(args.post) != os.path.abspath(archived_post_path):
20935 shutil.copy2(args.post, archived_post_path)
20937 solver_sources_deferred = bool(args.solve
and (cluster_mode
or args.no_submit))
20938 recompute = bool(getattr(args,
'recompute_post',
False))
20944 except ValueError
as exc:
20946 post_exe_error = exc
20953 recompute=recompute,
20954 allow_source_frontier_scan=
not solver_sources_deferred,
20955 postprocessor_sha=
_file_sha256(post_exe)
if post_exe
else None,
20959 needed_steps = post_plan[
'steps_to_process'] + post_plan[
'steps_unavailable']
20960 if 'fields' in post_stages
and not solver_sources_deferred
and needed_steps:
20962 require_storage_payload_local(
20963 run_dir,
"post-processing", checkpoints=needed_steps,
20965 except StorageError
as exc:
20967 ERROR_CODE_CFG_FILE_NOT_FOUND,
20971 hint=_STORAGE_RESTORE_HINT,
20975 post_finalize_command = (
20977 if 'fields' in post_stages
else []
20979 if post_stages != set(POST_STAGE_NAMES):
20980 print(f
"[INFO] Post stages selected: {','.join(sorted(post_stages))}")
20985 if stage
in post_stages
and not tasks:
20986 print(f
"[WARNING] --only selects {stage}, but {args.post} defines no {stage} "
20987 "tasks; nothing is measured for it.", file=sys.stderr)
20989 print(f
"[INFO] Post recipe: {recipe_id}")
20990 print(f
"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
20991 if post_plan[
'source_frontier_deferred']:
20992 print(
"[INFO] Source checkpoints: not scanned; the solver stage writes them before post starts.")
20993 elif post_plan[
'source_latest_step']
is not None:
20994 print(f
"[INFO] Source checkpoints: {post_plan['source_committed_count']} committed, "
20995 f
"latest at step {post_plan['source_latest_step']}.")
20997 print(
"[INFO] Source checkpoints: none committed yet.")
21001 executes_fields_now = (
21002 'fields' in post_stages
and post_plan[
'skip_reason']
is None
21003 and not cluster_mode
and not args.no_submit
21005 if 'fields' in post_stages:
21006 if not executes_fields_now:
21015 spectra_execute_now = (
21017 and not args.no_submit
21018 and not post_plan[
'source_frontier_deferred']
21020 fields_job_measures_spectra = (
21021 cluster_mode
and 'fields' in post_stages
and post_plan[
'skip_reason']
is None
21023 if 'spectra' in post_stages
and spectra_execute_now:
21026 post_plan[
'resolved_post_cfg'],
21028 post_plan[
'source_data_directory'],
21029 _iter_post_steps(post_plan[
'owned_start_step'], post_plan[
'requested_end_step'],
21030 post_plan[
'step_interval']),
21032 recompute=recompute,
21034 for artifact
in spectra_summary[
'artifacts']:
21035 print(f
"[INFO] Spectra output: {os.path.relpath(artifact)}")
21036 elif 'spectra' in post_stages
and not fields_job_measures_spectra:
21037 reason = (
"the solver stage has not produced output yet"
21038 if post_plan[
'source_frontier_deferred']
else
21039 "this invocation only stages work")
21040 print(f
"[INFO] Spectra deferred: {reason}. Measure them once the run has "
21041 f
"checkpoints with:")
21042 print(f
"[INFO] picurv run --post-process --only spectra "
21043 f
"--run-dir {os.path.relpath(run_dir)} --post {args.post}")
21047 if 'reducers' in post_stages
and 'fields' not in post_stages:
21050 except ValueError
as exc:
21051 print(f
"[FATAL] {exc}", file=sys.stderr)
21054 if 'fields' not in post_stages:
21056 print(
"[INFO] Skipping the field post-processor (--only "
21057 f
"{','.join(sorted(post_stages))}).")
21058 stages_completed.append(
'post-process')
21059 elif post_plan[
'skip_reason']
is not None:
21060 diagnostic = post_plan.get(
'source_frontier_diagnostic')
or {}
21061 first_incomplete = diagnostic.get(
'first_incomplete_step')
21062 if post_plan[
'skip_reason'] ==
'already-complete-window':
21063 print(
"[INFO] Every requested step is up to date; the postprocessor is not launched.")
21064 elif post_plan[
'skip_reason'] ==
'already-caught-up-to-current-source-frontier':
21065 print(
"[INFO] Every requested step with a committed checkpoint is up to date; "
21066 "the postprocessor is not launched.")
21068 print(
"[INFO] No requested step has a committed checkpoint yet; the "
21069 "postprocessor is not launched.")
21070 missing_files = diagnostic.get(
'missing_files_for_first_incomplete_step')
or []
21071 if first_incomplete
is not None and missing_files:
21072 print(f
"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
21073 if first_incomplete
is not None:
21075 "[INFO] Closest committed source steps: "
21076 f
"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
21077 f
"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
21079 if post_finalize_command
and post_plan[
'steps_current']:
21081 run_dir, post_plan[
'recipe_fingerprint'], post_finalize_command
21084 post_effective_cfg = post_plan[
'effective_post_cfg']
21085 post_io_cfg = post_effective_cfg.get(
'io', {})
21087 output_dir_rel = post_io_cfg[
'output_directory']
21088 output_prefix = post_io_cfg[
'output_filename_prefix']
21089 except KeyError
as e:
21090 print(f
"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
21093 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
21094 os.makedirs(output_dir_abs, exist_ok=
True)
21095 print(f
"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
21097 for stats_path
in statistics_output_paths:
21098 print(f
"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
21100 source_files_post = {
'Case': case_path,
'Post-Profile': args.post}
21102 run_dir, post_num_procs_effective, getattr(args,
"decomposition",
None),
21103 cluster_cfg
if cluster_mode
else None)
21104 if post_decomposition[
"problems"]:
21105 print(f
"[FATAL] {' '.join(post_decomposition['problems'])}", file=sys.stderr)
21107 print(f
"[INFO] Post-processing {describe_decomposition(post_decomposition, post_num_procs_effective)}")
21109 monitor_cfg, post_decomposition)
21110 if post_exe
is None:
21111 print(f
"[FATAL] {post_exe_error}", file=sys.stderr)
21115 solver_control_path,
21116 "-postprocessing_config_file",
21119 post_stage_meta = {
21120 "num_procs_effective": post_num_procs_effective,
21122 "steps_planned": len(post_plan[
'steps_to_process']),
21123 "steps_current": post_plan[
'steps_current'],
21124 "recompute": recompute,
21125 "source_frontier_step": post_plan[
'source_frontier_step'],
21126 "source_frontier_deferred": post_plan[
'source_frontier_deferred'],
21127 "recipe_fingerprint": post_plan[
'recipe_fingerprint'],
21128 "finalize_command": post_finalize_command,
21129 "submitted":
False,
21132 scheduler_dir = os.path.join(run_dir,
"scheduler")
21133 os.makedirs(scheduler_dir, exist_ok=
True)
21134 post_script = os.path.join(scheduler_dir,
"post.sbatch")
21135 post_log = os.path.join(scheduler_dir,
"post_%j.out")
21136 post_err = os.path.join(scheduler_dir,
"post_%j.err")
21137 post_cluster_cfg = cluster_cfg
21142 config_search_anchor=case_path,
21143 extra_search_anchors=[cluster_path],
21144 force_num_procs=post_num_procs_effective,
21148 post_plan[
'recipe_fingerprint'],
21150 create_wrapper=
True,
21151 success_command=post_finalize_command,
21152 post_recipe=archived_post_path,
21153 postprocessor=post_exe,
21154 recompute=recompute,
21156 spectra_follow = []
21157 if 'spectra' in post_stages:
21159 recompute=recompute)
21161 spectra_follow = [follow]
21162 print(
"[INFO] Spectra will be measured in the post job, after "
21163 "the field post-processor completes.")
21172 env_vars={
"LOG_LEVEL": monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
21173 follow_commands=spectra_follow,
21177 submission_meta[
"stages"][
"post-process"] = {
"script": post_script, **post_stage_meta}
21178 print(f
"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
21180 if not args.no_submit:
21181 dependency_job =
None
21183 dependency_job = submission_meta.get(
"stages", {}).get(
"solve", {}).get(
"job_id")
21184 submit_info =
submit_sbatch(post_script, dependency=dependency_job)
21185 submission_meta[
"stages"][
"post-process"].update(submit_info)
21186 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
21188 submission_meta[
"stages"][
"post-process"][
"dependency"] = f
"afterok:{dependency_job}"
21189 print(f
"[SUCCESS] Submitted post job: {submit_info['job_id']}")
21190 stages_completed.append(
'post-process')
21195 post_num_procs_effective,
21196 config_search_anchor=case_path,
21197 allow_single_rank_launcher_override=
True,
21198 force_num_procs=post_num_procs_effective,
21202 post_plan[
'recipe_fingerprint'],
21204 create_wrapper=
True,
21205 success_command=post_finalize_command,
21206 post_recipe=archived_post_path,
21207 postprocessor=post_exe,
21208 recompute=recompute,
21210 post_log = os.path.join(
"scheduler", f
"{run_id}_{output_prefix}.log")
21211 submission_meta[
"stages"][
"post-process"] = {
21212 "command": command,
21214 "log_file": post_log,
21218 print(f
"[SUCCESS] Staged local post command: {post_log}")
21221 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
21222 submission_meta[
"stages"][
"post-process"][
"executed"] =
True
21223 submission_meta[
"stages"][
"post-process"][
"completed_at"] = datetime.now().isoformat()
21224 stages_completed.append(
'post-process')
21228 manifest_inputs = {}
21233 if args.post_process:
21237 if submission_meta.get(
"stages"):
21240 submission_path = os.path.join(run_dir,
"scheduler",
"submission.json")
21241 for stage_name, stage_meta
in ((
_read_json_if_exists(submission_path)
or {}).get(
"stages")
or {}).items():
21242 submission_meta[
"stages"].setdefault(stage_name, stage_meta)
21244 asset_lock =
read_yaml_file(os.path.join(run_dir,
"inputs",
"assets.lock.yml")) \
21245 if os.path.isfile(os.path.join(run_dir,
"inputs",
"assets.lock.yml"))
else {}
21249 workspace_root=workspace_root,
21250 launch_mode=
"slurm" if cluster_mode
else "local",
21251 num_procs=solver_num_procs_effective,
21252 post_num_procs=post_num_procs_effective,
21253 stages_requested={
"solve": bool(args.solve),
"post_process": bool(args.post_process)},
21254 stages_completed=stages_completed,
21255 inputs=manifest_inputs,
21256 asset_lock=asset_lock,
21257 submission=submission_meta,
21258 lineage=run_lineage,
21262 if stages_completed:
21263 elapsed = time.time() - workflow_start
21264 mins, secs = divmod(int(elapsed), 60)
21265 hrs, mins = divmod(mins, 60)
21267 time_str = f
"{hrs}h {mins}m {secs}s"
21269 time_str = f
"{mins}m {secs}s"
21271 time_str = f
"{secs}s"
21273 print(
"\n" +
"=" * 60)
21274 print(
" RUN SUMMARY")
21276 print(f
" Run ID : {run_id}")
21277 print(f
" Run directory : {os.path.relpath(run_dir)}")
21279 staged = bool(cluster_mode
or args.no_submit)
21280 print(f
" Wall-clock : {time_str}")
21281 stages_label =
"Stages staged" if args.no_submit
else "Stages submitted" if staged
else "Stages"
21282 print(f
" {stages_label:<16}: {', '.join(stages_completed)}")
21283 print(f
" Launch mode : {'slurm' if cluster_mode else 'local'}")
21285 print(f
" Solver MPI procs: {solver_num_procs_effective}")
21286 if args.post_process:
21287 print(f
" Post MPI procs : {post_num_procs_effective}")
21288 if args.solve
and configs:
21289 total_steps = configs[
'case'].get(
'run_control', {}).get(
'total_steps',
'?')
21290 result_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS[
'output'])
21291 print(f
" {'Steps planned' if staged else 'Steps run':<16}: {total_steps}")
21292 print(f
" Solver output : {os.path.relpath(result_dir)}")
21293 if 'post-process' in stages_completed
and output_dir_abs:
21294 print(f
" Post output : {os.path.relpath(output_dir_abs)}")
21295 for stats_path
in statistics_output_paths:
21296 print(f
" Stats output : {os.path.relpath(stats_path)}")
21300 log_display = os.path.join(run_dir, CANONICAL_RUN_PATHS[
'logs'])
21301 relative_log = os.path.relpath(log_display)
21305 f
"{log_display if relative_log.startswith('..') else relative_log}")
21306 if cluster_mode
or submission_meta.get(
"stages"):
21307 submission_file = os.path.join(run_dir,
"scheduler",
"submission.json")
21308 print(f
" Submission meta : {os.path.relpath(submission_file)}")
21314 @brief Parse a case_index.tsv file back into a list of case entry dicts.
21315 @param[in] tsv_path Path to the case_index.tsv file.
21316 @return List of dicts with keys: index, case_id, run_dir, control_file,
21317 post_recipe_file, log_level, post_prefix.
21320 with open(tsv_path)
as f:
21322 line = line.strip()
21325 parts = line.split(
"\t")
21327 "index": int(parts[0]),
21328 "case_id": parts[1],
21329 "run_dir": parts[2],
21330 "control_file": parts[3],
21331 "post_recipe_file": parts[4],
21332 "log_level": parts[5],
21333 "post_prefix": parts[6],
21340 @brief Choose the executables an array stage launches for every member of a study.
21342 @details Array tasks share one script, which reads each member's `RUN_DIR` from the
21343 case index, so pinned members launch their own copy through the same
21344 `$RUN_DIR`-relative path. Members must agree: a study half pinned, or pinned
21345 at different paths, has no single command that launches each member's build.
21346 @param[in] run_dirs Member run directories the stage covers.
21347 @return Mapping of executable name to `(path token, staged --version line or None)`.
21348 @throws ValueError when members disagree about pinning.
21352 for name
in RUN_EXECUTABLE_NAMES:
21353 entries = [pin.get(name)
for pin
in pins]
21354 if not any(entries):
21359 os.path.relpath(entry[
"path"], run_dir).replace(os.sep,
"/")
if entry
else None
21360 for entry, run_dir
in zip(entries, run_dirs)
21362 lines = {entry.get(
"version_line")
if entry
else None for entry
in entries}
21363 if None in relatives
or len(relatives) != 1:
21365 f
"Study members disagree about the {name} they are pinned to; one array "
21366 "script cannot launch each member's build. Restage the study."
21368 chosen[name] = (f
"$RUN_DIR/{relatives.pop()}", lines.pop()
if len(lines) == 1
else None)
21374 @brief Study/sweep orchestration using Slurm job arrays.
21375 @param[in] args Command-line style argument list supplied to the function.
21377 study_path = os.path.abspath(args.study)
21378 cluster_path = os.path.abspath(args.cluster)
21390 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
21391 study_id = f
"{study_name}_{timestamp}"
21393 cases_dir = os.path.join(study_dir,
"cases")
21394 scheduler_dir = os.path.join(study_dir,
"scheduler")
21395 results_dir = os.path.join(study_dir,
"output",
"analysis")
21396 for path
in [cases_dir, scheduler_dir, results_dir, os.path.join(study_dir,
"logs")]:
21397 os.makedirs(path, exist_ok=
True)
21399 print(f
"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
21400 base_cfgs = study_cfg[
"base_configs"]
21401 base_paths = {k:
resolve_path(study_path, v)
for k, v
in base_cfgs.items()}
21402 base_snapshot_dir = os.path.join(study_dir,
"base_configs")
21403 os.makedirs(base_snapshot_dir, exist_ok=
True)
21404 portable_study_cfg = copy.deepcopy(study_cfg)
21405 portable_study_cfg[
"base_configs"] = {}
21406 for role
in (
"case",
"solver",
"monitor",
"post"):
21407 snapshot_name = f
"{role}.yml"
21408 snapshot_path = os.path.join(base_snapshot_dir, snapshot_name)
21409 shutil.copy2(base_paths[role], snapshot_path)
21410 portable_study_cfg[
"base_configs"][role] = os.path.join(
"base_configs", snapshot_name)
21411 shutil.copy2(study_path, os.path.join(study_dir,
"study.source.yml"))
21412 write_yaml_file(os.path.join(study_dir,
"study.yml"), portable_study_cfg)
21413 shutil.copy2(cluster_path, os.path.join(study_dir,
"cluster.yml"))
21419 validate_simulation_configs(base_case, base_solver, base_monitor, base_paths[
"case"], base_paths[
"solver"], base_paths[
"monitor"])
21423 if not combinations:
21424 print(
"[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
21426 print(f
"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
21430 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
21435 for combo
in combinations:
21436 member = {
"case": copy.deepcopy(base_case),
"monitor": copy.deepcopy(base_monitor),
21437 "solver": {},
"post": {}}
21438 for full_key, value
in combo.items():
21439 root, nested = full_key.split(
".", 1)
21445 widest = max(member_widths, default=(4,
"particle and grid fields fit 32-bit indices"))
21446 if requested == 4
and widest[0] == 8:
21447 raise ValueError(f
"--petsc-index-bits 32 cannot run every study member: {widest[1]}.")
21448 if requested
is not None:
21452 except ValueError
as exc:
21453 print(f
"[FATAL] {exc}", file=sys.stderr)
21456 for idx, combo
in enumerate(combinations):
21457 case_id = f
"case_{idx:04d}"
21458 run_dir = os.path.join(cases_dir, case_id)
21459 config_dir = os.path.join(run_dir,
"config")
21462 case_cfg = copy.deepcopy(base_case)
21463 solver_cfg = copy.deepcopy(base_solver)
21464 monitor_cfg = copy.deepcopy(base_monitor)
21465 post_cfg = copy.deepcopy(base_post)
21466 target_map = {
"case": case_cfg,
"solver": solver_cfg,
"monitor": monitor_cfg,
"post": post_cfg}
21467 for full_key, value
in combo.items():
21468 root, nested = full_key.split(
".", 1)
21469 _deep_set(target_map[root], nested, value)
21475 case_path = os.path.join(config_dir,
"case.yml")
21476 solver_path = os.path.join(config_dir,
"solver.yml")
21477 monitor_path = os.path.join(config_dir,
"monitor.yml")
21478 post_path = os.path.join(config_dir,
"post.yml")
21484 "schema_version": 1,
21485 "revision":
"initial",
21486 "updated_at": datetime.now().astimezone().isoformat(),
21488 "case":
"config/case.yml",
21489 "solver":
"config/solver.yml",
21490 "monitor":
"config/monitor.yml",
21497 except ValueError
as exc:
21498 print(f
"[FATAL] {exc}", file=sys.stderr)
21508 except ValueError
as exc:
21509 print(f
"[FATAL] Study case {case_id}: {exc}", file=sys.stderr)
21512 source_files = {
'Case': case_path,
'Solver': solver_path,
'Monitor': monitor_path}
21515 "case": case_cfg,
"case_path": case_path,
21516 "solver": solver_cfg,
"solver_path": solver_path,
21517 "monitor": monitor_cfg,
"monitor_path": monitor_path,
21519 "cluster_cfg": cluster_cfg,
21524 [control_file, monitor_files.get(
"whitelist"), monitor_files.get(
"profile"),
21525 *glob.glob(os.path.join(config_dir,
"bcs*.run"))],
21529 if not isinstance(post_cfg.get(
'source_data'), dict):
21530 post_cfg[
'source_data'] = {}
21531 post_cfg[
'source_data'][
'directory'] = source_dir
21534 post_cfg[
"_picurv_paths"][
"visualization"],
21535 post_cfg[
"_picurv_paths"][
"statistics"],
21536 post_cfg[
"_picurv_paths"][
"spectra"],
21538 os.makedirs(os.path.join(run_dir, relative), exist_ok=
True)
21539 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix",
"post")
21541 if post_decomposition[
"problems"]:
21542 print(f
"[FATAL] Study case {case_id}: {' '.join(post_decomposition['problems'])}", file=sys.stderr)
21545 monitor_cfg, post_decomposition)
21548 case_entries.append({
21550 "case_id": case_id,
21551 "run_dir": os.path.abspath(run_dir),
21552 "control_file": control_file,
21553 "post_recipe_file": post_recipe,
21554 "log_level": str(monitor_cfg.get(
"logging", {}).get(
"verbosity",
"INFO")).upper(),
21555 "post_prefix": output_prefix,
21558 "parameters": combo,
21561 os.path.join(run_dir,
"manifest.json"),
21563 run_dir, case_id, workspace_root=workspace_root, launch_mode=
"slurm",
21564 artifact_type=
"study-case", study_id=study_id, case_id=case_id,
21565 num_procs=cluster_tasks, post_num_procs=cluster_tasks,
21566 stages_requested={
"solve":
True,
"post_process":
True},
21573 asset_lock=asset_lock,
21577 with open(case_index_file,
"w")
as f:
21578 for entry
in case_entries:
21582 str(entry[
"index"]),
21585 entry[
"control_file"],
21586 entry[
"post_recipe_file"],
21587 entry[
"log_level"],
21588 entry[
"post_prefix"],
21589 entry[
"solve_diagnostic_args"],
21590 entry[
"post_diagnostic_args"],
21594 print(f
"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
21596 max_idx = len(case_entries) - 1
21597 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
21598 array_spec = f
"0-{max_idx}"
21600 array_spec = f
"{array_spec}%{max_conc}"
21603 solver_exe, solver_version_line = stage_executables[
"simulator"]
21604 post_exe, post_version_line = stage_executables[
"postprocessor"]
21605 solver_array_script = os.path.join(scheduler_dir,
"solver_array.sbatch")
21606 post_array_script = os.path.join(scheduler_dir,
"post_array.sbatch")
21608 solver_array_script,
21609 f
"{study_id}_solve",
21616 os.path.join(scheduler_dir,
"solver_%A_%a.out"),
21617 os.path.join(scheduler_dir,
"solver_%A_%a.err"),
21618 expected_version_line=solver_version_line,
21622 f
"{study_id}_post",
21629 os.path.join(scheduler_dir,
"post_%A_%a.out"),
21630 os.path.join(scheduler_dir,
"post_%A_%a.err"),
21631 expected_version_line=post_version_line,
21633 print(f
"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
21635 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
21636 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_aggregate.sbatch")
21638 metrics_aggregate_script,
21639 f
"{study_id}_metrics",
21644 print(f
"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
21647 "launch_mode":
"slurm",
21648 "study_id": study_id,
21649 "solver_array": {
"script": solver_array_script,
"submitted":
False},
21650 "post_array": {
"script": post_array_script,
"submitted":
False},
21651 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
21652 "no_submit": bool(args.no_submit),
21654 if not args.no_submit:
21656 submission[
"solver_array"].update(solver_submit)
21657 submission[
"solver_array"][
"submitted"] =
True
21658 post_submit =
submit_sbatch(post_array_script, dependency=solver_submit[
"job_id"])
21659 submission[
"post_array"].update(post_submit)
21660 submission[
"post_array"][
"submitted"] =
True
21661 submission[
"post_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
21662 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
21663 submission[
"metrics_aggregate"].update(metrics_submit)
21664 submission[
"metrics_aggregate"][
"submitted"] =
True
21665 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
21666 print(f
"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
21667 print(f
"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
21668 print(f
"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
21674 "schema_version": 2,
21675 "artifact_type":
"study",
21676 "study_id": study_id,
21677 "created_at": datetime.now().isoformat(),
21679 "study_type": study_cfg.get(
"study_type"),
21680 "num_cases": len(case_entries),
21682 "study_dir": study_dir,
21683 "case_index": case_index_file,
21684 "solver_array_script": solver_array_script,
21685 "post_array_script": post_array_script,
21686 "metrics_table": metrics_csv,
21687 "plots_dir": os.path.join(results_dir,
"plots"),
21689 "submission": submission,
21691 write_json_file(os.path.join(scheduler_dir,
"submission.json"), submission)
21692 write_json_file(os.path.join(study_dir,
"study_manifest.json"), summary)
21693 write_json_file(os.path.join(results_dir,
"summary.json"), {
"study_id": study_id,
"metrics_csv": metrics_csv,
"plots": plots})
21695 print(
"\n" +
"=" * 60)
21696 print(
" STUDY SUMMARY")
21698 print(f
" Study ID : {study_id}")
21699 print(f
" Study directory : {os.path.relpath(study_dir)}")
21700 print(f
" Cases generated : {len(case_entries)}")
21701 print(f
" Array spec : {array_spec}")
21702 print(f
" Solver script : {os.path.relpath(solver_array_script)}")
21703 print(f
" Post script : {os.path.relpath(post_array_script)}")
21705 print(f
" Submit with : picurv submit --study-dir {os.path.relpath(study_dir)}")
21707 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
21709 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
21715 @brief Continue a partially-completed Slurm parameter sweep study.
21716 @details Detects incomplete cases, prepares them for continuation (updating
21717 start_step, populating restart directories, regenerating control files),
21718 and submits new solver/post/metrics Slurm jobs. If all cases are already
21719 complete, performs metrics aggregation automatically.
21720 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
21722 study_dir = os.path.abspath(args.study_dir)
21723 manifest_path = os.path.join(study_dir,
"study_manifest.json")
21724 if not os.path.isfile(manifest_path):
21726 message=
"Study manifest not found.", hint=_STUDY_DIR_HINT)
21730 study_id = manifest[
"study_id"]
21732 study_path = os.path.join(study_dir,
"study.yml")
21733 cluster_path = os.path.abspath(args.cluster)
if args.cluster
else os.path.join(study_dir,
"cluster.yml")
21740 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir,
"cluster.yml"))
21741 print(f
"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
21743 scheduler_dir = os.path.join(study_dir,
"scheduler")
21744 cases_dir = os.path.join(study_dir,
"cases")
21745 results_dir = os.path.join(study_dir,
"output",
"analysis")
21746 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
21747 if not os.path.isfile(case_index_file):
21748 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
21753 cold_cases = cold_study_members(study_dir)
21754 if cold_cases
and getattr(args,
"auto_fetch",
False):
21756 "[INFO] --auto-fetch: restoring cold-storage member(s) before continuing: "
21757 +
", ".join(cold_cases)
21760 restore_cold_study_members(study_dir, cold_cases)
21761 except StorageError
as exc:
21762 print(f
"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
21764 cold_cases = cold_study_members(study_dir)
21767 "[FATAL] Study continuation requires payload from cold-storage member(s): "
21768 +
", ".join(cold_cases),
21771 for case_id
in cold_cases:
21772 state = storage_state_summary(os.path.join(cases_dir, case_id))
21774 f
" Restore {case_id} with: picurv storage restore --archive-id "
21775 f
"{state.get('archive_id') or '<archive-id>'}",
21779 " Or pass --auto-fetch to restore them automatically.",
21786 base_cfgs = study_cfg[
"base_configs"]
21787 base_paths = {k: os.path.join(study_dir, v)
for k, v
in base_cfgs.items()}
21791 if len(combinations) != len(parsed_entries):
21793 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
21794 f
"case_index.tsv ({len(parsed_entries)} entries).",
21799 print(f
"\n[INFO] Study: {study_id}")
21800 print(f
"[INFO] Scanning {len(combinations)} case(s) for completion status...")
21802 incomplete_indices = []
21803 all_case_entries = []
21804 for idx, combo
in enumerate(combinations):
21805 case_id = f
"case_{idx:04d}"
21806 entry = parsed_entries[idx]
21807 entry[
"parameters"] = combo
21808 run_dir = entry[
"run_dir"]
21810 effective_case = copy.deepcopy(base_case)
21811 for full_key, value
in combo.items():
21812 root, nested = full_key.split(
".", 1)
21814 _deep_set(effective_case, nested, value)
21816 eff_start = int(effective_case.get(
"run_control", {}).get(
"start_step", 0)
or 0)
21817 except (TypeError, ValueError):
21819 eff_total = int(effective_case[
"run_control"][
"total_steps"])
21820 target = eff_start + eff_total
21822 monitor_cfg =
read_yaml_file(os.path.join(run_dir,
"config",
"monitor.yml"))
21824 entry[
"_status"] = status
21826 if status[
"status"] ==
"complete":
21827 print(f
" {case_id}: complete (step {status['last_step']}/{target})")
21828 elif status[
"status"] ==
"partial":
21829 print(f
" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
21830 incomplete_indices.append(idx)
21832 print(f
" {case_id}: no checkpoint — will re-run from scratch")
21833 incomplete_indices.append(idx)
21835 all_case_entries.append(entry)
21837 if not incomplete_indices:
21838 print(
"\n[INFO] All cases are complete. Running metrics aggregation...")
21841 print(
"\n" +
"=" * 60)
21842 print(
" STUDY CONTINUATION SUMMARY")
21844 print(f
" Study ID : {study_id}")
21845 print(f
" Status : ALL COMPLETE")
21847 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
21849 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
21853 print(f
"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
21856 for idx
in incomplete_indices:
21857 entry = all_case_entries[idx]
21858 status = entry[
"_status"]
21859 if status[
"status"] ==
"partial":
21861 entry[
"run_dir"], entry[
"case_id"],
21862 status[
"last_step"], status[
"target_step"],
21865 elif status[
"status"] ==
"empty":
21866 print(f
"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
21868 solver_array_spec =
",".join(str(i)
for i
in incomplete_indices)
21869 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
21871 solver_array_spec = f
"{solver_array_spec}%{max_conc}"
21873 max_idx = len(combinations) - 1
21874 post_array_spec = f
"0-{max_idx}"
21876 post_array_spec = f
"{post_array_spec}%{max_conc}"
21880 [entry[
"run_dir"]
for entry
in all_case_entries]
21882 except ValueError
as exc:
21883 print(f
"[FATAL] {exc}", file=sys.stderr)
21885 solver_exe, solver_version_line = stage_executables[
"simulator"]
21886 post_exe, post_version_line = stage_executables[
"postprocessor"]
21889 for entry
in all_case_entries:
21892 solver_continue_script = os.path.join(scheduler_dir,
"solver_continue_array.sbatch")
21893 post_continue_script = os.path.join(scheduler_dir,
"post_continue_array.sbatch")
21895 solver_continue_script,
21896 f
"{study_id}_solve_cont",
21901 solver_exe, post_exe,
21902 os.path.join(scheduler_dir,
"solver_cont_%A_%a.out"),
21903 os.path.join(scheduler_dir,
"solver_cont_%A_%a.err"),
21904 expected_version_line=solver_version_line,
21907 post_continue_script,
21908 f
"{study_id}_post_cont",
21913 solver_exe, post_exe,
21914 os.path.join(scheduler_dir,
"post_cont_%A_%a.out"),
21915 os.path.join(scheduler_dir,
"post_cont_%A_%a.err"),
21916 expected_version_line=post_version_line,
21919 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
21920 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_continue_aggregate.sbatch")
21922 metrics_aggregate_script,
21923 f
"{study_id}_metrics_cont",
21928 print(f
"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
21931 "launch_mode":
"slurm",
21932 "study_id": study_id,
21933 "continuation":
True,
21934 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
21935 "solver_continue_array": {
"script": solver_continue_script,
"submitted":
False},
21936 "post_continue_array": {
"script": post_continue_script,
"submitted":
False},
21937 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
21938 "no_submit": bool(args.no_submit),
21940 if not args.no_submit:
21942 submission[
"solver_continue_array"].update(solver_submit)
21943 submission[
"solver_continue_array"][
"submitted"] =
True
21944 post_submit =
submit_sbatch(post_continue_script, dependency=solver_submit[
"job_id"])
21945 submission[
"post_continue_array"].update(post_submit)
21946 submission[
"post_continue_array"][
"submitted"] =
True
21947 submission[
"post_continue_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
21948 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
21949 submission[
"metrics_aggregate"].update(metrics_submit)
21950 submission[
"metrics_aggregate"][
"submitted"] =
True
21951 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
21952 print(f
"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
21953 print(f
"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
21954 print(f
"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
21956 write_json_file(os.path.join(scheduler_dir,
"submission_continue.json"), submission)
21958 manifest[
"continuation"] = {
21959 "continued_at": datetime.now().isoformat(),
21960 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
21961 "submission": submission,
21965 print(
"\n" +
"=" * 60)
21966 print(
" STUDY CONTINUATION SUMMARY")
21968 print(f
" Study ID : {study_id}")
21969 print(f
" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
21970 print(f
" Solver array spec : {solver_array_spec}")
21971 print(f
" Post array spec : {post_array_spec}")
21972 print(f
" Solver script : {os.path.relpath(solver_continue_script)}")
21973 print(f
" Post script : {os.path.relpath(post_continue_script)}")
21974 print(f
" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
21975 if not args.no_submit:
21976 print(f
" [Metrics aggregation will run automatically after post-processing]")
21978 print(f
" [--no-submit] Scripts generated but not submitted. Submit them with:")
21979 print(f
" picurv submit --study-dir {os.path.relpath(study_dir)}")
21985 @brief Re-run metrics aggregation and plot generation for an existing study.
21986 @param[in] args Parsed CLI arguments with study_dir.
21988 study_dir = os.path.abspath(args.study_dir)
21989 study_path = os.path.join(study_dir,
"study.yml")
21990 if not os.path.isfile(study_path):
21992 message=
"Study config not found.", hint=_STUDY_DIR_HINT)
21998 case_index_file = os.path.join(study_dir,
"scheduler",
"case_index.tsv")
21999 if not os.path.isfile(case_index_file):
22000 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
22004 cold_cases = cold_study_members(study_dir)
22005 if cold_cases
and getattr(args,
"auto_fetch",
False):
22007 "[INFO] --auto-fetch: restoring cold-storage member(s) before aggregating: "
22008 +
", ".join(cold_cases)
22011 restore_cold_study_members(study_dir, cold_cases)
22012 except StorageError
as exc:
22013 print(f
"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
22015 cold_cases = cold_study_members(study_dir)
22018 "[INFO] Cold-storage member(s) cannot be re-measured; their previously "
22019 "aggregated values are carried forward: " +
", ".join(cold_cases)
22022 if len(combinations) != len(parsed_entries):
22024 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
22025 f
"case_index.tsv ({len(parsed_entries)} entries).",
22031 for idx, combo
in enumerate(combinations):
22032 entry = parsed_entries[idx]
22033 entry[
"parameters"] = combo
22034 case_entries.append(entry)
22036 results_dir = os.path.join(study_dir,
"output",
"analysis")
22040 print(
"\n" +
"=" * 60)
22041 print(
" REAGGREGATION SUMMARY")
22044 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
22046 print(f
" Plots generated : {len(plots)}")
22050_SUMMARY_NUMERIC_RE = re.compile(
r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
22055 @brief Read YAML when present, otherwise return None.
22056 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
22057 @return Value returned by `_read_yaml_if_exists()`.
22059 if not filepath
or not os.path.isfile(filepath):
22062 with open(filepath,
"r", encoding=
"utf-8")
as f:
22063 return yaml.safe_load(f)
22064 except yaml.YAMLError:
22070 @brief Read JSON when present, otherwise return None.
22071 @param[in] filepath Argument passed to `_read_json_if_exists()`.
22072 @return Value returned by `_read_json_if_exists()`.
22074 if not filepath
or not os.path.isfile(filepath):
22076 with open(filepath,
"r", encoding=
"utf-8")
as f:
22077 return json.load(f)
22082 @brief Best-effort integer parsing for summary extraction.
22083 @param[in] value Argument passed to `_parse_int_loose()`.
22084 @return Value returned by `_parse_int_loose()`.
22088 text = str(value).strip()
22095 return int(float(text))
22102 @brief Best-effort float parsing for summary extraction.
22103 @param[in] value Argument passed to `_parse_float_loose()`.
22104 @return Value returned by `_parse_float_loose()`.
22108 text = str(value).strip()
22119 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
22120 @param[in] text Argument passed to `_extract_numeric_tuple()`.
22121 @return Value returned by `_extract_numeric_tuple()`.
22125 return [float(token)
for token
in _SUMMARY_NUMERIC_RE.findall(text)]
22130 @brief Resolve run-local config and artifact paths for summarize.
22131 @param[in] run_dir Argument passed to `_build_summary_context()`.
22132 @return Value returned by `_build_summary_context()`.
22134 run_dir = os.path.abspath(run_dir)
22135 if not os.path.isdir(run_dir):
22137 ERROR_CODE_CFG_FILE_NOT_FOUND,
22140 message=
"Run directory not found.",
22144 config_dir = os.path.join(run_dir,
"config")
22146 "case": os.path.join(config_dir,
"case.yml"),
22147 "solver": os.path.join(config_dir,
"solver.yml"),
22148 "monitor": os.path.join(config_dir,
"monitor.yml"),
22155 io_cfg = monitor_cfg.get(
"io", {})
if isinstance(monitor_cfg, dict)
else {}
22156 scheduler_dir = os.path.join(run_dir,
"scheduler")
22158 profiling_cfg = {
"mode":
"off",
"functions": [],
"final_summary_enabled":
True,
"helpers": []}
22162 particle_console_output_freq =
None
22163 particle_log_interval =
None
22166 particle_log_interval = io_cfg.get(
"particle_log_interval")
22168 particle_count_cfg =
None
22170 particle_count_cfg = (
22171 case_cfg.get(
"models", {})
22172 .get(
"physics", {})
22173 .get(
"particles", {})
22178 "run_dir": run_dir,
22179 "config_dir": config_dir,
22180 "log_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS[
"logs"]),
22181 "metrics_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS[
"metrics"]),
22182 "scheduler_dir": scheduler_dir,
22183 "monitor_cfg": monitor_cfg,
22184 "case_cfg": case_cfg,
22185 "solver_cfg": solver_cfg,
22186 "config_paths": config_paths,
22187 "manifest": manifest,
22188 "profiling_cfg": profiling_cfg,
22189 "particle_console_output_freq": particle_console_output_freq,
22190 "particle_log_interval": particle_log_interval,
22191 "particle_count_cfg": particle_count_cfg,
22197 @brief Return one explicitly requested copied config or fail with a structured error.
22198 @param[in] context Summary context returned by `_build_summary_context()`.
22199 @param[in] name Config selector name.
22200 @return Parsed config mapping.
22202 path = context[
"config_paths"][name]
22203 cfg = context.get(f
"{name}_cfg")
22204 if not os.path.isfile(path):
22206 ERROR_CODE_CFG_FILE_NOT_FOUND,
22209 message=f
"Copied run config '{name}.yml' was not found.",
22210 hint=
"Use a staged run directory containing the requested copied config.",
22213 if not isinstance(cfg, dict)
or not cfg:
22215 ERROR_CODE_CFG_INVALID_VALUE,
22218 message=f
"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
22226 @brief Build timestep-independent run metadata for summarize.
22227 @param[in] context Summary context returned by `_build_summary_context()`.
22228 @return Curated run metadata mapping.
22230 manifest = context[
"manifest"]
22231 software = manifest.get(
"software")
if isinstance(manifest.get(
"software"), dict)
else {}
22233 "run_id": manifest.get(
"run_id", os.path.basename(context[
"run_dir"])),
22234 "run_dir": context[
"run_dir"],
22235 "created_at": manifest.get(
"created_at"),
22236 "launch_mode": manifest.get(
"launch_mode"),
22237 "release_version": software.get(
"release_version"),
22238 "build_id": software.get(
"build_id"),
22239 "git_commit": software.get(
"git_commit", manifest.get(
"git_commit")),
22240 "solver_num_procs": manifest.get(
"solver_num_procs", manifest.get(
"num_procs")),
22241 "post_num_procs": manifest.get(
"post_num_procs"),
22242 "stages_requested": manifest.get(
"stages_requested"),
22243 "stages_completed_or_submitted": manifest.get(
"stages_completed_or_submitted"),
22249 @brief Build compact turbulence and wall-model selections.
22250 @param[in] turbulence_cfg Case turbulence configuration mapping.
22251 @return Curated turbulence and wall-model mapping.
22254 for key
in (
"les",
"wall_function"):
22255 value = turbulence_cfg.get(key)
22256 if isinstance(value, dict):
22258 "enabled": value.get(
"enabled",
True),
22259 "model": value.get(
"model"),
22260 **{k: v
for k, v
in value.items()
if k
not in {
"enabled",
"model"}},
22262 elif value
is not None:
22263 result[key] = value
22269 @brief Build a curated case.yml summary with useful derived quantities.
22270 @param[in] context Summary context returned by `_build_summary_context()`.
22271 @return Curated case configuration mapping.
22274 props = cfg.get(
"properties", {})
22275 scaling = props.get(
"scaling", {})
22276 fluid = props.get(
"fluid", {})
22277 run = cfg.get(
"run_control", {})
22278 grid = cfg.get(
"grid", {})
22279 models = cfg.get(
"models", {})
22280 domain = models.get(
"domain", {})
22281 physics = models.get(
"physics", {})
22282 particles = physics.get(
"particles", {})
22283 start = int(run.get(
"start_step", 0))
22284 total = int(run.get(
"total_steps", 0))
22285 dt = float(run.get(
"dt_physical", 0.0))
22286 length_ref = float(scaling.get(
"length_ref"))
22287 velocity_ref = float(scaling.get(
"velocity_ref"))
22288 density = float(fluid.get(
"density"))
22289 viscosity = float(fluid.get(
"viscosity"))
22291 first_block_faces = {row[
"face"]: row
for row
in prepared_bcs[0]}
22293 "i": first_block_faces[
"-Xi"][
"type"] ==
"PERIODIC",
22294 "j": first_block_faces[
"-Eta"][
"type"] ==
"PERIODIC",
22295 "k": first_block_faces[
"-Zeta"][
"type"] ==
"PERIODIC",
22298 for block_idx, block
in enumerate(prepared_bcs):
22301 "block": block_idx,
22303 {
"face": row[
"face"],
"type": row[
"type"],
"handler": row[
"handler"]}
22310 "start_step": start,
22311 "total_steps": total,
22312 "end_step": start + total,
22314 "duration_physical": total * dt,
22316 dt, (
"case",
"run_control",
"dt_physical"),
22317 {
"length_ref": length_ref,
"velocity_ref": velocity_ref}),
22320 "length_ref": length_ref,
22321 "velocity_ref": velocity_ref,
22322 "density": density,
22323 "viscosity": viscosity,
22324 "reynolds_number": density * velocity_ref * length_ref / viscosity
if viscosity
else None,
22325 "initial_conditions": props.get(
"initial_conditions", {}),
22328 "mode": grid.get(
"mode"),
22330 "programmatic_settings": grid.get(
"programmatic_settings")
if grid.get(
"mode") ==
"programmatic_c" else None,
22331 "source_file": grid.get(
"source_file"),
22334 "blocks": domain.get(
"blocks", 1),
22335 "dimensionality": physics.get(
"dimensionality",
"3D"),
22336 "periodic": periodic_axes,
22339 "fsi": physics.get(
"fsi", {}),
22340 "particles": particles,
22343 "boundary_conditions": bc_blocks,
22349 @brief Scalar-transport parameters as the run resolves them, wherever they are set.
22350 @param[in] context Summary context returned by `_build_summary_context()`.
22351 @param[in] solver_cfg Parsed solver configuration.
22352 @return Mapping of parameter name to value; empty when unresolvable.
22362 @brief Build a curated solver.yml summary with normalized selections.
22363 @param[in] context Summary context returned by `_build_summary_context()`.
22364 @return Curated solver configuration mapping.
22367 strategy = cfg.get(
"strategy", {})
or {}
22369 momentum_cfg = cfg.get(
"momentum_solver", {})
or {}
22370 dualtime = momentum_cfg.get(
"dual_time_picard_jameson_rk", momentum_cfg.get(
"dual_time_picard_rk4", {}))
or {}
22371 newton_krylov = momentum_cfg.get(
"newton_krylov", {})
or {}
22372 poisson = cfg.get(
"poisson_solver", cfg.get(
"pressure_solver", {}))
or {}
22373 operation_mode = cfg.get(
"operation_mode", {})
or {}
22378 if operation_mode.get(
"analytical_type")
is not None:
22380 passthrough = cfg.get(
"petsc_passthrough_options", {})
or {}
22382 "operation_mode": operation_mode,
22385 "central_diff": bool(strategy.get(
"central_diff",
False)),
22386 "tolerances": cfg.get(
"tolerances", {}),
22387 "controls": newton_krylov
if selected ==
"newton_krylov" else dualtime,
22389 "poisson": poisson,
22390 "interpolation": cfg.get(
"interpolation", {
"method":
"Trilinear"}),
22392 "verification": cfg.get(
"verification", {}),
22393 "petsc_passthrough": {
"count": len(passthrough),
"options": sorted(passthrough.keys())},
22399 @brief Build a curated monitor.yml summary with resolved defaults.
22400 @param[in] context Summary context returned by `_build_summary_context()`.
22401 @return Curated monitor configuration mapping.
22404 logging_cfg = cfg.get(
"logging", {})
or {}
22405 io_cfg = cfg.get(
"io", {})
or {}
22409 enabled_petsc = sorted(key
for key, value
in diagnostics[
"petsc"].items()
if value
not in (
False,
None))
22412 "verbosity": logging_cfg.get(
"verbosity",
"WARNING"),
22413 "enabled_functions": logging_cfg.get(
"enabled_functions", []),
22417 "enabled_petsc": enabled_petsc,
22418 "petsc": diagnostics[
"petsc"],
22419 "runtime_memory_log": diagnostics[
"runtime_memory_log"],
22422 "data_output_frequency": io_cfg.get(
"data_output_frequency"),
22424 "particle_log_interval": io_cfg.get(
"particle_log_interval"),
22426 "output": CANONICAL_RUN_PATHS[
"output"],
22427 "restart": CANONICAL_RUN_PATHS[
"restart"],
22428 "logs": CANONICAL_RUN_PATHS[
"logs"],
22429 "analysis": CANONICAL_RUN_PATHS[
"analysis"],
22432 "solver_monitoring": {
22433 "enabled_flags": sorted(flag
for flag, value
in monitoring_flags.items()
if value
not in (
False,
None)),
22434 "flags": monitoring_flags,
22436 "solution_monitoring": solution_monitoring,
22442 @brief Read the continuity record into rows by step (one per block) plus observed order.
22443 @param[in] log_dir Run log directory.
22444 @return Mapping step -> list of block rows, and the order steps first appeared.
22446 rows_by_step, step_order = {}, []
22449 if step
not in rows_by_step:
22450 step_order.append(step)
22451 rows_by_step[step] = {}
22452 rows_by_step[step][row[
"block"]] = {key: value
for key, value
in row.items()
if key !=
"step"}
22453 return {step:
list(blocks.values())
for step, blocks
in rows_by_step.items()}, step_order
22458 @brief Read the per-step particle record into rows by step plus observed order.
22459 @param[in] log_dir Run log directory.
22460 @param[in] metrics_dir Run analysis metrics directory.
22461 @return Mapping step -> row, and the steps in file order.
22463 rows_by_step, step_order = {}, []
22465 rows_by_step[row[
"step"]] = {key: value
for key, value
in row.items()
if key !=
"step"}
22466 step_order.append(row[
"step"])
22467 return rows_by_step, step_order
22472 @brief Read the per-block momentum records (Picard history, Newton summary) into rows by step.
22473 @param[in] log_dir Run log directory.
22474 @return Rows by step and block, source path per block, and step order.
22476 rows_by_step, sources, step_order = {}, {}, []
22480 sources[block] = path
22481 for _, row
in rows:
22483 step_order.append(step)
22484 entry = state.setdefault((step, block), {
"accepted_count": 0,
"rejected_count": 0,
22485 "last_accepted":
None,
"last_rejected":
None})
22486 display = {key: value
for key, value
in row.items()
if key !=
"step"}
22487 if row.get(
"status") ==
"accepted":
22488 entry[
"accepted_count"] += 1
22489 entry[
"last_accepted"] = display
22491 entry[
"rejected_count"] += 1
22492 entry[
"last_rejected"] = display
22493 for (step, block), entry
in state.items():
22494 display_row = entry[
"last_accepted"]
or entry[
"last_rejected"]
22496 rows_by_step.setdefault(step, {})[block] = {
22498 "accepted_count": entry[
"accepted_count"],
22499 "rejected_count": entry[
"rejected_count"],
22502 sources[file_block] = path
22503 for _, row
in rows:
22504 step_order.append(row[
"step"])
22505 rows_by_step.setdefault(row[
"step"], {})[row[
"block"]] = {
22506 key: value
for key, value
in row.items()
if key !=
"step"}
22507 return rows_by_step, sources, step_order
22512 @brief Read the per-block Poisson histories into each step's last iteration.
22513 @param[in] log_dir Run log directory.
22514 @return Rows by step and block, source path per block, and step order.
22516 rows_by_step, sources, step_order = {}, {}, []
22518 sources[block] = path
22519 for _, row
in rows:
22520 step_order.append(row[
"step"])
22521 rows_by_step.setdefault(row[
"step"], {})[block] = {
22522 key: value
for key, value
in row.items()
if key
not in (
"step",)}
22523 return rows_by_step, sources, step_order
22528 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
22529 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
22530 @return Value returned by `_parse_profiling_timestep_csv()`.
22535 if not os.path.isfile(filepath):
22536 return rows_by_step, step_order
22538 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
22539 reader = csv.DictReader(f)
22544 if step != active_step:
22546 step_order.append(step)
22547 rows_by_step[step] = []
22548 rows_by_step.setdefault(step, []).append(
22550 "function": row.get(
"function"),
22555 return rows_by_step, step_order
22560 @brief Read the runtime memory record into rows by step and its final status.
22561 @param[in] filepath Runtime memory log path.
22562 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
22564 rows_by_step, step_order = {}, []
22565 final_row = latest_sample_row = max_process_change_mb =
None
22567 if not os.path.isfile(filepath):
22568 return rows_by_step, step_order, {
"available":
False}
22570 if row.get(
"step_wall_s")
is not None:
22571 step_walls.append(row[
"step_wall_s"])
22572 change = row.get(
"process_change_mb_max")
22573 if change
is not None:
22574 max_process_change_mb = change
if max_process_change_mb
is None else max(max_process_change_mb, change)
22575 if row[
"event"]
in {
"Step",
"Post"}:
22576 rows_by_step[row[
"step"]] = row
22577 step_order.append(row[
"step"])
22578 latest_sample_row = row
22579 elif row[
"event"]
in {
"Shutdown",
"Final"}:
22582 "available": bool(rows_by_step
or final_row),
22583 "source": filepath,
22584 "final_event": final_row.get(
"event")
if final_row
else None,
22585 "final_reason": final_row.get(
"reason")
if final_row
else None,
22586 "max_process_change_mb": max_process_change_mb,
22587 "mean_step_wall_s": (sum(step_walls) / len(step_walls))
if step_walls
else None,
22588 "latest_sample_row": latest_sample_row,
22589 "final_row": final_row,
22591 return rows_by_step, step_order, meta
22596 @brief Read the solution-convergence record into rows by step plus observed order.
22597 @param[in] log_dir Run log directory.
22598 @return Mapping step -> row of every column, and the steps in file order.
22600 rows_by_step, step_order = {}, []
22602 rows_by_step[row[
"step"]] = row
22603 step_order.append(row[
"step"])
22604 return rows_by_step, step_order
22609 @brief Return plausible solver stream logs for local and Slurm runs.
22610 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
22611 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
22612 @return Value returned by `_find_solver_stream_log_candidates()`.
22615 os.path.join(run_dir,
"scheduler",
"*_solver.log"),
22616 os.path.join(run_dir,
"scheduler",
"solver_*.out"),
22617 os.path.join(log_dir,
"*_solver.log"),
22621 for pattern
in patterns:
22622 for path
in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=
True):
22623 if path
not in seen:
22631 @brief Parse sampled particle snapshots from a solver stream log.
22632 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
22633 @return Value returned by `_parse_particle_snapshot_file()`.
22636 if not os.path.isfile(filepath):
22639 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
22640 lines = f.readlines()
22643 while idx < len(lines):
22644 match = re.search(
r"Particle states at step\s+(\d+):", lines[idx])
22648 step = int(match.group(1))
22651 while idx < len(lines):
22652 stripped = lines[idx].strip()
22653 if re.search(
r"Particle states at step\s+\d+:", lines[idx]):
22655 if stripped.startswith(
"|"):
22656 parts = [part.strip()
for part
in stripped.split(
"|")[1:-1]]
22657 if len(parts) >= 6
and parts[0] !=
"Rank":
22665 "velocity": velocity,
22667 "sample_speed": math.sqrt(sum(component * component
for component
in velocity))
if len(velocity) == 3
else None,
22670 elif rows
and (
not stripped
or stripped.startswith(
"Progress:")):
22674 snapshots[step] = rows
22676 snapshots.setdefault(step, [])
22682 @brief Return the nearest earlier snapshot step when available.
22683 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
22684 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
22685 @return Value returned by `_find_previous_snapshot_step()`.
22687 earlier_steps = [candidate
for candidate
in snapshot_steps
if candidate < step]
22688 if not earlier_steps:
22690 return max(earlier_steps)
22695 @brief Compute sampled deltas between two particle snapshot samples.
22696 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
22697 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
22698 @return Value returned by `_compute_particle_snapshot_delta()`.
22701 previous_by_pid = {
22702 row.get(
"pid"): row
22703 for row
in previous_rows
22704 if row.get(
"pid")
is not None
22707 row.get(
"pid"): row
22708 for row
in current_rows
22709 if row.get(
"pid")
is not None
22711 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
22712 if not matched_pids:
22713 return {
"available":
False}
22716 rank_migrations = 0
22719 for pid
in matched_pids:
22720 current_row = current_by_pid[pid]
22721 previous_row = previous_by_pid[pid]
22722 current_pos = current_row.get(
"position")
or []
22723 previous_pos = previous_row.get(
"position")
or []
22724 if len(current_pos) == len(previous_pos)
and current_pos:
22725 displacements.append(
22728 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
22729 for idx
in range(len(current_pos))
22733 current_speed = current_row.get(
"sample_speed")
22734 previous_speed = previous_row.get(
"sample_speed")
22735 if current_speed
is not None and previous_speed
is not None:
22736 speed_changes.append(float(current_speed) - float(previous_speed))
22737 if current_row.get(
"rank")
is not None and previous_row.get(
"rank")
is not None:
22738 if current_row[
"rank"] != previous_row[
"rank"]:
22739 rank_migrations += 1
22740 if current_row.get(
"cell")
and previous_row.get(
"cell"):
22741 if current_row[
"cell"] != previous_row[
"cell"]:
22746 "matched_pids": len(matched_pids),
22747 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
22748 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
22749 "rank_migrations": rank_migrations,
22750 "cell_changes": cell_changes,
22753 payload[
"mean_displacement"] = float(np.mean(displacements))
22754 payload[
"max_displacement"] = float(np.max(displacements))
22756 payload[
"mean_speed_change"] = float(np.mean(speed_changes))
22757 payload[
"max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
22764 rows:
"list[dict]",
22766 particle_console_output_freq,
22767 particle_log_interval,
22768 previous_step:
"int | None" =
None,
22769 previous_rows:
"list[dict] | None" =
None,
22772 @brief Build sampled diagnostics for one particle console snapshot.
22773 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
22774 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
22775 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
22776 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
22777 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
22778 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
22779 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
22780 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
22781 @return Value returned by `_build_particle_snapshot_summary()`.
22789 "sampled_rows": len(rows),
22790 "preview_rows": rows[:preview_rows],
22792 "particle_console_output_frequency": particle_console_output_freq,
22793 "particle_log_interval": particle_log_interval,
22800 duplicate_pid_count = 0
22801 duplicate_cell_count = 0
22804 zero_weight_count = 0
22805 negative_weight_count = 0
22806 unique_pid_count = 0
22810 position_components = [[], [], []]
22811 weight_components = {}
22815 pid = row.get(
"pid")
22816 if pid
is not None:
22817 if pid
in seen_pids:
22818 duplicate_pid_count += 1
22821 rank = row.get(
"rank")
22822 if rank
is not None:
22823 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
22825 cell = row.get(
"cell")
or []
22828 cell_counter[key] = cell_counter.get(key, 0) + 1
22830 position = row.get(
"position")
or []
22831 for idx, value
in enumerate(position[:3]):
22832 if not np.isfinite(value):
22833 if np.isnan(value):
22838 position_components[idx].append(float(value))
22840 velocity = row.get(
"velocity")
or []
22841 if any(
not np.isfinite(value)
for value
in velocity):
22842 for value
in velocity:
22843 if not np.isfinite(value):
22844 if np.isnan(value):
22848 speed = row.get(
"sample_speed")
22849 if speed
is not None and np.isfinite(speed):
22850 speeds.append(float(speed))
22852 weights = row.get(
"weights")
or []
22853 for idx, value
in enumerate(weights):
22854 if not np.isfinite(value):
22855 if np.isnan(value):
22860 numeric = float(value)
22861 weight_components.setdefault(idx, []).append(numeric)
22862 if abs(numeric) <= 1.0e-15:
22863 zero_weight_count += 1
22865 negative_weight_count += 1
22867 unique_pid_count = len(seen_pids)
22868 duplicate_cell_count = sum(1
for count
in cell_counter.values()
if count > 1)
22870 payload[
"sampled_distribution"] = {
22871 "unique_cells": len(cell_counter),
22872 "duplicate_cells": duplicate_cell_count,
22873 "rank_counts": rank_counts,
22874 "unique_pids": unique_pid_count,
22876 payload[
"checks"] = {
22877 "duplicate_pid_count": duplicate_pid_count,
22878 "nan_count": nan_count,
22879 "inf_count": inf_count,
22880 "zero_weight_count": zero_weight_count,
22881 "negative_weight_count": negative_weight_count,
22885 payload[
"speed"] = {
22886 "min": float(np.min(speeds)),
22887 "mean": float(np.mean(speeds)),
22888 "max": float(np.max(speeds)),
22889 "std": float(np.std(speeds)),
22890 "stagnant_count": sum(1
for speed
in speeds
if abs(speed) < 1.0e-6),
22893 fastest_rows = sorted(
22894 [row
for row
in rows
if row.get(
"sample_speed")
is not None],
22895 key=
lambda row: row[
"sample_speed"],
22898 payload[
"top_speeds"] = [
22900 "pid": row.get(
"pid"),
22901 "rank": row.get(
"rank"),
22902 "speed": row.get(
"sample_speed"),
22903 "cell": row.get(
"cell"),
22905 for row
in fastest_rows
22908 if any(position_components):
22909 axes = [
"x",
"y",
"z"]
22910 payload[
"position_bounds"] = {}
22912 for idx, axis
in enumerate(axes):
22913 values = position_components[idx]
22915 payload[
"position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
22916 centroid.append(float(np.mean(values)))
22918 centroid.append(
None)
22919 payload[
"position_centroid"] = centroid
22921 if weight_components:
22922 payload[
"weights"] = {}
22923 for idx, values
in sorted(weight_components.items()):
22924 payload[
"weights"][f
"component_{idx}"] = {
22925 "min": float(np.min(values)),
22926 "max": float(np.max(values)),
22929 delta_summary = {
"available":
False}
22930 if previous_step
is not None and previous_rows:
22932 if delta_summary.get(
"available"):
22933 delta_summary[
"previous_step"] = previous_step
22934 payload[
"delta_from_previous_snapshot"] = delta_summary
22943 particle_console_output_freq,
22944 particle_log_interval,
22947 @brief Locate and summarize a particle console snapshot for one step.
22948 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
22949 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
22950 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
22951 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
22952 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
22953 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
22954 @return Value returned by `_find_particle_snapshot_for_step()`.
22959 rows = snapshots.get(step)
22962 if best
is None or len(rows) > len(best[
"rows"]):
22963 best = {
"source": path,
"rows": rows,
"snapshots": snapshots}
22966 return {
"available":
False}
22969 previous_rows = best[
"snapshots"].get(previous_step, [])
if previous_step
is not None else None
22975 particle_console_output_freq=particle_console_output_freq,
22976 particle_log_interval=particle_log_interval,
22977 previous_step=previous_step,
22978 previous_rows=previous_rows,
22990 convergence_rows=None,
22992 selection_mode: str =
"latest",
22995 @brief Select a step to summarize from available metric artifacts.
22996 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
22997 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
22998 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
22999 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
23000 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
23001 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
23002 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
23003 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
23004 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
23005 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
23006 @return Value returned by `_resolve_summary_step()`.
23008 if memory_rows
is None:
23010 if convergence_rows
is None:
23011 convergence_rows = {}
23012 if step_orders
is None:
23014 available_steps = (
23015 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
23016 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
23018 if not available_steps:
23021 if requested_step
is not None:
23022 return requested_step, sorted(available_steps)
23024 if selection_mode ==
"max_step":
23025 return max(available_steps), sorted(available_steps)
23027 for order
in step_orders:
23029 return order[-1], sorted(available_steps)
23030 return max(available_steps), sorted(available_steps)
23035 @brief Format optional numeric values for summary text output.
23036 @param[in] value Argument passed to `_format_summary_float()`.
23037 @param[in] spec Argument passed to `_format_summary_float()`.
23038 @param[in] missing Argument passed to `_format_summary_float()`.
23039 @return Value returned by `_format_summary_float()`.
23043 return format(value, spec)
23048 @brief Return the newest modification time among one or more summary sources.
23049 @param[in] paths Path string, iterable of paths, or mapping of paths.
23050 @return Newest modification time, or -1.0 when no source exists.
23052 if isinstance(paths, dict):
23053 paths = paths.values()
23054 elif isinstance(paths, str):
23057 for path
in paths
or []:
23058 if path
and os.path.isfile(path):
23059 newest = max(newest, os.path.getmtime(path))
23065 @brief Order observed step sequences by the recency of their source files.
23066 @param[in] sources Pairs of observed steps and filesystem source path(s).
23067 @return Step-order lists sorted so active append sources are considered first.
23070 for priority, (order, paths)
in enumerate(sources):
23073 ranked.sort(key=
lambda item: (-item[0], item[1]))
23074 return [order
for _, _, order
in ranked]
23079 @brief Build a read-only run-step summary from existing PICurv artifacts.
23080 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
23081 @param[in] step Argument passed to `build_run_summary_payload()`.
23082 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
23083 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
23084 @return Value returned by `build_run_summary_payload()`.
23087 log_dir = context[
"log_dir"]
23088 metrics_dir = context[
"metrics_dir"]
23090 or os.path.join(log_dir,
"Continuity_Metrics.csv"))
23092 or os.path.join(metrics_dir,
"search_metrics.csv"))
23099 profiling_rows = {}
23100 profiling_order = []
23101 profiling_path = os.path.join(log_dir, PROFILING_TIMESTEP_FILE)
23102 if context[
"profiling_cfg"].get(
"mode") !=
"off":
23105 memory_path =
_log_family_path(log_dir,
"Runtime_Memory")
or os.path.join(log_dir, RUNTIME_MEMORY_FILE)
23109 or os.path.join(log_dir,
"solution_convergence.csv"))
23113 (continuity_order, continuity_path),
23114 (particle_order, particle_metrics_path),
23115 (convergence_order, convergence_log_path),
23116 (profiling_order, profiling_path),
23117 (memory_order, memory_path),
23118 (momentum_order, momentum_sources),
23119 (poisson_order, poisson_sources),
23132 step_orders=step_orders,
23133 selection_mode=selection_mode,
23135 if resolved_step
is None:
23137 ERROR_CODE_CFG_FILE_NOT_FOUND,
23140 message=
"No summary-capable run artifacts were found under the run log directory.",
23141 hint=
"Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
23145 if step
is not None and step
not in set(available_steps):
23147 ERROR_CODE_CFG_INVALID_VALUE,
23149 file_path=context[
"run_dir"],
23150 message=f
"Requested step {step} is not present in the available summary artifacts.",
23151 hint=f
"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
23155 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=
lambda row: row[
"block"])
23156 continuity_summary = {
"available": bool(continuity_step_rows),
"blocks": continuity_step_rows}
23157 if continuity_step_rows:
23158 divergence_values = [
23159 abs(row[
"max_divergence"])
23160 for row
in continuity_step_rows
23161 if row[
"max_divergence"]
is not None
23163 continuity_summary[
"max_abs_divergence"] = max(divergence_values)
if divergence_values
else None
23164 continuity_summary[
"net_flux"] = continuity_step_rows[0].get(
"net_flux")
23165 continuity_summary[
"flux_in"] = continuity_step_rows[0].get(
"flux_in")
23166 continuity_summary[
"flux_out"] = continuity_step_rows[0].get(
"flux_out")
23168 momentum_step_rows = [row
for _, row
in sorted(momentum_rows.get(resolved_step, {}).items())]
23169 momentum_summary = {
"available": bool(momentum_step_rows),
"blocks": momentum_step_rows}
23171 poisson_step_rows = [row
for _, row
in sorted(poisson_rows.get(resolved_step, {}).items())]
23172 poisson_summary = {
"available": bool(poisson_step_rows),
"blocks": poisson_step_rows}
23174 particle_summary = {
"available": resolved_step
in particle_rows}
23175 if resolved_step
in particle_rows:
23176 particle_summary.update(particle_rows[resolved_step])
23178 profiling_summary = {
"available": resolved_step
in profiling_rows}
23179 if resolved_step
in profiling_rows:
23180 functions = sorted(
23181 profiling_rows[resolved_step],
23182 key=
lambda row: (row.get(
"step_time_s")
or 0.0),
23185 profiling_summary[
"functions"] = functions
23186 profiling_summary[
"total_logged_step_time_s"] = sum(
23187 row.get(
"step_time_s")
or 0.0
for row
in functions
23190 memory_summary = {
"available": resolved_step
in memory_rows}
23191 if resolved_step
in memory_rows:
23192 memory_summary.update(memory_rows[resolved_step])
23193 memory_summary[
"source"] = memory_path
23194 memory_summary[
"max_process_change_mb"] = memory_meta.get(
"max_process_change_mb")
23195 memory_summary[
"mean_step_wall_s"] = memory_meta.get(
"mean_step_wall_s")
23196 memory_summary[
"final_event"] = memory_meta.get(
"final_event")
23197 memory_summary[
"final_reason"] = memory_meta.get(
"final_reason")
23198 memory_summary[
"selected_step"] = resolved_step
23199 memory_summary[
"step_match"] =
True
23200 elif memory_meta.get(
"available"):
23201 latest_sample_row = memory_meta.get(
"latest_sample_row")
23202 if latest_sample_row:
23203 memory_summary.update(latest_sample_row)
23204 memory_summary.update(memory_meta)
23205 memory_summary[
"selected_step"] = resolved_step
23206 memory_summary[
"step_match"] =
False
23208 snapshot_summary = {
"available":
False}
23209 if context[
"particle_console_output_freq"]
and context[
"particle_console_output_freq"] > 0:
23211 context[
"run_dir"],
23214 preview_rows=max(1, snapshot_rows),
23215 particle_console_output_freq=context[
"particle_console_output_freq"],
23216 particle_log_interval=context[
"particle_log_interval"],
23220 "profiling_timestep_mode": context[
"profiling_cfg"].get(
"mode"),
23221 "profiling_timestep_file": PROFILING_TIMESTEP_FILE,
23222 "particle_console_output_frequency": context[
"particle_console_output_freq"],
23223 "particle_log_interval": context[
"particle_log_interval"],
23227 "run_id": context[
"manifest"].get(
"run_id", os.path.basename(context[
"run_dir"])),
23228 "run_dir": context[
"run_dir"],
23229 "step": resolved_step,
23230 "selected_via":
"explicit" if step
is not None else (
"max_step" if selection_mode ==
"max_step" else "latest_available"),
23231 "available_steps": available_steps,
23232 "launch_mode": context[
"manifest"].get(
"launch_mode"),
23233 "created_at": context[
"manifest"].get(
"created_at"),
23234 "monitor": monitor_info,
23235 "particles_configured": context[
"particle_count_cfg"],
23237 "continuity_log": continuity_path
if os.path.isfile(continuity_path)
else None,
23238 "particle_metrics_log": particle_metrics_path
if os.path.isfile(particle_metrics_path)
else None,
23239 "momentum_logs": momentum_sources,
23240 "poisson_logs": poisson_sources,
23241 "profiling_timestep_csv": profiling_path
if os.path.isfile(profiling_path)
else None,
23242 "solution_convergence_log": convergence_log_path
if os.path.isfile(convergence_log_path)
else None,
23243 "runtime_memory_log": memory_path
if os.path.isfile(memory_path)
else None,
23245 "continuity": continuity_summary,
23246 "momentum": momentum_summary,
23247 "poisson": poisson_summary,
23248 "particles": particle_summary,
23249 "particle_snapshot": snapshot_summary,
23250 "profiling": profiling_summary,
23251 "memory": memory_summary,
23252 "convergence": convergence_rows.get(resolved_step)
if convergence_rows
else None,
23258 @brief Render a run-step summary in human or JSON form.
23259 @param[in] payload Argument passed to `render_run_summary()`.
23260 @param[in] output_format Argument passed to `render_run_summary()`.
23262 if output_format ==
"json":
23263 print(json.dumps(payload, indent=2, sort_keys=
True))
23266 print(
"\n" +
"=" * 60)
23267 print(
" RUN STEP SUMMARY")
23269 print(f
" Run ID : {payload.get('run_id')}")
23270 print(f
" Run directory : {os.path.relpath(payload.get('run_dir'))}")
23271 print(f
" Step : {payload.get('step')} ({payload.get('selected_via')})")
23272 if payload.get(
"launch_mode"):
23273 print(f
" Launch mode : {payload.get('launch_mode')}")
23274 if payload.get(
"created_at"):
23275 print(f
" Created at : {payload.get('created_at')}")
23277 continuity = payload.get(
"continuity", {})
23278 print(
"\n Continuity:")
23279 if continuity.get(
"available"):
23280 if continuity.get(
"max_abs_divergence")
is not None:
23281 print(f
" max |div| : {continuity['max_abs_divergence']:.6e}")
23282 if continuity.get(
"net_flux")
is not None:
23283 print(f
" net flux : {continuity['net_flux']:.6e}")
23284 for row
in continuity.get(
"blocks", []):
23287 f
"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
23288 f
"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
23291 print(
" unavailable")
23293 momentum = payload.get(
"momentum", {})
23294 print(
"\n Momentum:")
23295 if momentum.get(
"available"):
23296 for row
in momentum.get(
"blocks", []):
23297 if row.get(
"solver") ==
"Newton Krylov":
23298 print(f
" block {row['block']}: solver=Newton Krylov")
23300 f
" newton={row.get('newton_iterations')} "
23301 f
"krylov={row.get('krylov_iterations')} "
23302 f
"evals={row.get('residual_evaluations')}"
23305 f
" final={_format_summary_float(row.get('final_norm'))} "
23306 f
"reason={row.get('reason')} state={row.get('state')}"
23309 status = row.get(
"status")
or "unknown"
23310 accepted = row.get(
"accepted_count")
23311 rejected = row.get(
"rejected_count")
23312 counts_str = f
" accepted={accepted} rejected={rejected}" if accepted
is not None else ""
23314 dtau_val = row.get(
"dtau")
23315 cfl_in = row.get(
"cfl_eff")
23316 cfl_out = row.get(
"cfl_eff_after")
23317 dtau_out = row.get(
"dtau_after")
23318 if cfl_in
is not None and cfl_out
is not None:
23319 cfl_str = f
"cfl_eff {cfl_in:.4f}->{cfl_out:.4f} dtau {_format_summary_float(dtau_val)}->{_format_summary_float(dtau_out)}"
23320 elif cfl_in
is not None:
23321 cfl_str = f
"cfl_eff={_format_summary_float(cfl_in, '.4f')} dtau={_format_summary_float(dtau_val)}"
23323 cfl_str =
"cfl_eff=n/a"
23324 ratio = row.get(
"trial_ratio")
23325 smoothed = row.get(
"smoothed_ratio")
23326 if ratio
is not None and smoothed
is not None:
23327 ratio_str = f
" ratio={_format_summary_float(ratio)} (ema={_format_summary_float(smoothed)})"
23328 elif ratio
is not None:
23329 ratio_str = f
" ratio={_format_summary_float(ratio)}"
23332 print(f
" block {row['block']} [{status}]:{counts_str} {cfl_str}{ratio_str}")
23334 f
" resid={_format_summary_float(row.get('residual_norm'))}"
23335 f
" delta={_format_summary_float(row.get('delta_norm'))}"
23338 print(
" unavailable")
23340 poisson = payload.get(
"poisson", {})
23341 print(
"\n Poisson:")
23342 if poisson.get(
"available"):
23343 for row
in poisson.get(
"blocks", []):
23346 f
"block {row['block']}: iter={row['iterations']} "
23347 f
"true={_format_summary_float(row.get('true_norm'))} "
23348 f
"rel={_format_summary_float(row.get('relative_norm'))}"
23351 print(
" unavailable")
23353 convergence = payload.get(
"convergence")
23354 print(
"\n Solution Convergence:")
23355 if convergence
is not None:
23356 mode = convergence.get(
"mode",
"unknown")
23357 ref = convergence.get(
"ref")
23358 print(f
" mode : {mode} (ref={'yes' if ref else 'no'})")
23359 if mode
in (
"steady_deterministic",
"transient"):
23360 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
23361 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
23362 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
23363 elif mode ==
"periodic_deterministic":
23364 ph = convergence.get(
"ph")
23365 per = convergence.get(
"per")
23366 print(f
" phase : {ph}/{per}")
23367 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
23368 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
23369 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
23370 elif mode ==
"statistical_steady":
23371 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'))}")
23372 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'))}")
23374 print(
" unavailable")
23376 particles = payload.get(
"particles", {})
23377 print(
"\n Particles:")
23378 if particles.get(
"available"):
23379 loss_summary = f
"lost={particles.get('lost_particles')}"
23380 if particles.get(
"lost_particles_cumulative")
is not None:
23382 f
"lost(step/total)={particles.get('lost_particles')}/"
23383 f
"{particles.get('lost_particles_cumulative')}"
23385 if particles.get(
"exited_particles_cumulative")
is not None:
23387 f
" exited(step/total)={particles.get('exited_particles')}/"
23388 f
"{particles.get('exited_particles_cumulative')}"
23392 f
"total={particles.get('total_particles')} {loss_summary} "
23393 f
"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
23394 f
"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
23397 print(
" unavailable")
23399 memory = payload.get(
"memory", {})
23400 print(
"\n Runtime Memory:")
23401 if memory.get(
"available"):
23402 if memory.get(
"source"):
23403 print(f
" source : {os.path.relpath(memory.get('source'))}")
23404 if memory.get(
"step")
is not None and not memory.get(
"step_match",
True):
23405 print(f
" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
23406 if memory.get(
"event"):
23407 print(f
" event : {memory.get('event')} reason={memory.get('reason', '-')}")
23408 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")
23409 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")
23410 print(f
" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
23411 if memory.get(
"step_wall_s")
is not None or memory.get(
"mean_step_wall_s")
is not None:
23412 print(f
" step time : {_format_summary_float(memory.get('step_wall_s'), '.4g')} s this step, "
23413 f
"{_format_summary_float(memory.get('mean_step_wall_s'), '.4g')} s mean (slowest rank)")
23414 if memory.get(
"final_reason"):
23415 print(f
" final reason : {memory.get('final_reason')}")
23417 print(
" final reason : none recorded (still running, or stopped without a final row)")
23419 print(
" unavailable")
23421 snapshot = payload.get(
"particle_snapshot", {})
23422 if snapshot.get(
"available"):
23423 print(
"\n Particle Snapshot (sampled):")
23424 print(f
" source : {os.path.relpath(snapshot.get('source'))}")
23425 cadence = snapshot.get(
"cadence", {})
23428 f
"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
23429 f
"row interval {cadence.get('particle_log_interval', 'n/a')}"
23431 print(f
" sampled rows : {snapshot.get('sampled_rows')}")
23432 speed = snapshot.get(
"speed", {})
23436 f
"sampled speeds: min={_format_summary_float(speed.get('min'))} "
23437 f
"mean={_format_summary_float(speed.get('mean'))} "
23438 f
"max={_format_summary_float(speed.get('max'))} "
23439 f
"std={_format_summary_float(speed.get('std'))} "
23440 f
"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
23442 bounds = snapshot.get(
"position_bounds", {})
23443 centroid = snapshot.get(
"position_centroid")
23446 for axis
in (
"x",
"y",
"z"):
23448 bound_parts.append(
23449 f
"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
23451 print(f
" sampled bounds: {' '.join(bound_parts)}")
23455 f
"sampled center: ({_format_summary_float(centroid[0])}, "
23456 f
"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
23458 distribution = snapshot.get(
"sampled_distribution", {})
23462 f
"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
23463 f
"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
23464 f
"unique_pids={distribution.get('unique_pids', 'n/a')} "
23465 f
"ranks={distribution.get('rank_counts', {})}"
23467 weights = snapshot.get(
"weights", {})
23470 for component, summary
in sorted(weights.items()):
23471 weight_parts.append(
23472 f
"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
23474 print(f
" sampled weights: {' '.join(weight_parts)}")
23475 checks = snapshot.get(
"checks", {})
23479 f
"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
23480 f
"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
23481 f
"zero_weight={checks.get('zero_weight_count', 0)} "
23482 f
"negative_weight={checks.get('negative_weight_count', 0)}"
23484 top_speeds = snapshot.get(
"top_speeds", [])
23486 summary =
", ".join(
23487 f
"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
23488 for row
in top_speeds
23490 print(f
" top speeds : {summary}")
23491 delta_summary = snapshot.get(
"delta_from_previous_snapshot", {})
23492 if delta_summary.get(
"available"):
23495 f
"vs prev snap : step={delta_summary.get('previous_step')} "
23496 f
"matched_pids={delta_summary.get('matched_pids')} "
23497 f
"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
23498 f
"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
23499 f
"rank_moves={delta_summary.get('rank_migrations')} "
23500 f
"cell_changes={delta_summary.get('cell_changes')} "
23501 f
"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
23503 print(
" preview rows :")
23504 for row
in snapshot.get(
"preview_rows", []):
23507 f
"pid={row.get('pid')} rank={row.get('rank')} "
23508 f
"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
23511 profiling = payload.get(
"profiling", {})
23512 print(
"\n Profiling:")
23513 if profiling.get(
"available"):
23514 print(f
" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
23515 for row
in profiling.get(
"functions", [])[:5]:
23518 f
"{row.get('function')}: calls={row.get('calls')} "
23519 f
"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
23522 print(
" unavailable")
23526_CONFIG_SUMMARY_WIDTH = 78
23531 @brief Format one configuration-summary value for compact text output.
23532 @param[in] value Value to format.
23533 @return Compact human-readable value.
23537 if isinstance(value, bool):
23538 return "enabled" if value
else "disabled"
23539 if isinstance(value, float):
23540 return f
"{value:.6g}"
23541 if isinstance(value, (list, tuple)):
23543 if isinstance(value, dict):
23546 return ", ".join(f
"{key}={_summary_display_value(item)}" for key, item
in value.items())
23552 @brief Print a strong dashboard-style configuration summary header.
23553 @param[in] title Section title.
23554 @param[in] subtitle Optional one-line section subtitle.
23556 print(
"\n" +
"=" * _CONFIG_SUMMARY_WIDTH)
23557 print(f
"{title:^78}")
23559 print(f
"{subtitle:^78}")
23560 print(
"=" * _CONFIG_SUMMARY_WIDTH)
23565 @brief Print an aligned configuration-summary field group.
23566 @param[in] title Group title.
23567 @param[in] rows Sequence of `(label, value)` pairs.
23569 visible_rows = [(label, value)
for label, value
in rows
if value
is not None]
23570 if not visible_rows:
23572 print(f
"\n {title}")
23573 print(f
" {'-' * (len(title) + 1)}")
23574 for label, value
in visible_rows:
23575 print(f
" {label:<32} {_summary_display_value(value)}")
23580 @brief Flatten nested summary mappings into readable dotted field rows.
23581 @param[in] mapping Mapping to flatten.
23582 @param[in] prefix Optional parent-field prefix.
23583 @return Sequence of `(field, value)` pairs.
23586 for key, value
in mapping.items():
23587 label = f
"{prefix}.{key}" if prefix
else str(key)
23588 if isinstance(value, dict)
and value:
23591 rows.append((label, value))
23597 @brief Render run metadata as a compact dashboard.
23598 @param[in] summary Curated run overview mapping.
23604 (
"Run directory", os.path.relpath(summary.get(
"run_dir"))
if summary.get(
"run_dir")
else None),
23605 (
"Created", summary.get(
"created_at")),
23606 (
"Launch mode", summary.get(
"launch_mode")),
23607 (
"PICurv release", summary.get(
"release_version")),
23608 (
"Build", summary.get(
"build_id")),
23609 (
"Git commit", summary.get(
"git_commit")),
23615 (
"Solver MPI processes", summary.get(
"solver_num_procs")),
23616 (
"Post MPI processes", summary.get(
"post_num_procs")),
23617 (
"Stages requested", summary.get(
"stages_requested")),
23618 (
"Stages ready/completed", summary.get(
"stages_completed_or_submitted")),
23625 @brief Render the case summary as a glanceable simulation dashboard.
23626 @param[in] summary Curated case configuration mapping.
23628 run = summary.get(
"run_control", {})
23629 props = summary.get(
"properties", {})
23630 grid = summary.get(
"grid", {})
23631 domain = summary.get(
"domain", {})
23632 physics = summary.get(
"physics", {})
23634 f
"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
23635 f
"Re={_summary_display_value(props.get('reynolds_number'))}"
23641 (
"Step range", f
"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
23642 (
"Physical timestep", run.get(
"dt_physical")),
23643 (
"Nondimensional timestep", run.get(
"dt_nondimensional")),
23644 (
"Physical duration", run.get(
"duration_physical")),
23645 (
"Initial conditions", props.get(
"initial_conditions")),
23649 "Fluid And Scaling",
23651 (
"Reynolds number", props.get(
"reynolds_number")),
23652 (
"Reference length", props.get(
"length_ref")),
23653 (
"Reference velocity", props.get(
"velocity_ref")),
23654 (
"Density", props.get(
"density")),
23655 (
"Viscosity", props.get(
"viscosity")),
23661 (
"Grid mode", grid.get(
"mode")),
23662 (
"Blocks", domain.get(
"blocks")),
23663 (
"Dimensionality", domain.get(
"dimensionality")),
23664 (
"Periodic axes", domain.get(
"periodic")),
23665 (
"MPI grid layout", grid.get(
"processor_layout")),
23666 (
"Grid source", grid.get(
"source_file")),
23669 if grid.get(
"programmatic_settings"):
23674 (
"Particles", physics.get(
"particles")),
23675 (
"FSI", physics.get(
"fsi")),
23676 (
"Turbulence", physics.get(
"turbulence")),
23677 (
"Statistics", physics.get(
"statistics")),
23680 boundary_blocks = summary.get(
"boundary_conditions", [])
23681 if boundary_blocks:
23682 print(
"\n Boundary Conditions")
23683 print(
" --------------------")
23684 print(f
" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
23685 print(f
" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
23686 for block
in boundary_blocks:
23687 for face
in block.get(
"faces", []):
23689 f
" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
23690 f
"{face.get('type', '-'):<12} {face.get('handler', '-')}"
23696 @brief Render the solver summary as a glanceable numerical-method dashboard.
23697 @param[in] summary Curated solver configuration mapping.
23699 momentum = summary.get(
"momentum", {})
23700 poisson = summary.get(
"poisson", {})
23701 operation = summary.get(
"operation_mode", {})
23703 f
"Field: {operation.get('eulerian_field_source', '-')} | "
23704 f
"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
23711 (
"Momentum solver", momentum.get(
"type")),
23712 (
"Central differencing", momentum.get(
"central_diff")),
23713 (
"Poisson method", poisson.get(
"method")),
23714 (
"Interpolation", summary.get(
"interpolation")),
23715 (
"Convergence mode", summary.get(
"solution_convergence", {}).get(
"mode")),
23719 control_heading = (
23720 "Newton--Krylov Controls" if momentum.get(
"type") ==
"newton_krylov"
23721 else "Dual-Time Pseudo-Time Controls" if momentum.get(
"type") ==
"DUALTIME_PICARD_JAMESON_RK"
23722 else "Momentum Controls"
23729 passthrough = summary.get(
"petsc_passthrough", {})
23731 "Advanced PETSc Options",
23732 [(
"Option count", passthrough.get(
"count")), (
"Option names", passthrough.get(
"options"))],
23738 @brief Render the monitor summary as a glanceable observability dashboard.
23739 @param[in] summary Curated monitor configuration mapping.
23741 logging_cfg = summary.get(
"logging", {})
23742 profiling = summary.get(
"profiling", {})
23743 diagnostics = summary.get(
"diagnostics", {})
23744 io_cfg = summary.get(
"io", {})
23745 memory_log = diagnostics.get(
"runtime_memory_log", {})
23747 f
"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
23748 f
"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
23754 (
"Verbosity", logging_cfg.get(
"verbosity")),
23755 (
"Enabled functions", logging_cfg.get(
"enabled_functions")),
23762 (
"Field output", io_cfg.get(
"data_output_frequency")),
23763 (
"Particle snapshots", io_cfg.get(
"particle_console_output_frequency")),
23764 (
"Particle row interval", io_cfg.get(
"particle_log_interval")),
23771 (
"Enabled PETSc diagnostics", diagnostics.get(
"enabled_petsc")),
23772 (
"Runtime memory log", memory_log.get(
"enabled")),
23776 solver_monitoring = summary.get(
"solver_monitoring", {})
23778 "Solver Monitoring",
23780 (
"Enabled flags", solver_monitoring.get(
"enabled_flags")),
23781 (
"All flags", solver_monitoring.get(
"flags")),
23788 @brief Render selected timestep-independent config views and optional health.
23789 @param[in] payload Combined selected summary payload.
23790 @param[in] output_format Output format.
23792 if output_format ==
"json":
23793 json_payload = {key: value
for key, value
in payload.items()
if key !=
"_health_requested"}
23794 print(json.dumps(json_payload, indent=2, sort_keys=
True))
23797 if payload.get(
"run_overview")
is not None:
23800 "case": _render_case_summary_text,
23801 "solver": _render_solver_summary_text,
23802 "monitor": _render_monitor_summary_text,
23804 for key
in (
"case",
"solver",
"monitor"):
23805 if key
in payload.get(
"configuration", {}):
23806 renderers[key](payload[
"configuration"][key])
23807 storage = payload.get(
"storage")
23808 if isinstance(storage, dict):
23811 print(f
" State : {storage.get('state', 'LOCAL')}")
23812 if storage.get(
"archive_id"):
23813 print(f
" Archive ID : {storage['archive_id']}")
23814 if storage.get(
"label"):
23815 print(f
" Label : {storage['label']}")
23816 if payload.get(
"_health_requested"):
23818 key: value
for key, value
in payload.items()
23819 if key
not in {
"run_overview",
"configuration",
"storage",
"_health_requested"}
23824_SUMMARY_PLOT_LOG_SCALE_FIELDS = {
23825 "max_divergence",
"delta_norm",
"delta_rel",
"residual_norm",
"residual_rel",
23826 "unpreconditioned_norm",
"true_norm",
"relative_norm",
23827 "u_abs_l2",
"u_rel_l2",
"p_abs_l2",
"p_rel_l2",
23828 "spd_abs",
"spd_rel",
"ke_abs",
"ke_rel",
23829 "spd_win_abs",
"spd_win_rel",
"spd_rms_abs",
"spd_rms_rel",
23830 "ke_win_abs",
"ke_win_rel",
"ke_rms_abs",
"ke_rms_rel",
23831 "parseval_residual",
"zero_mode_energy",
23835_SUMMARY_PLOT_FIELD_LABELS = {
23836 "max_divergence":
"Maximum |divergence|",
23837 "rhs_sum":
"Poisson source imbalance",
23838 "flux_in":
"Inflow flux",
23839 "flux_out":
"Outflow flux",
23840 "net_flux":
"Net boundary flux",
23841 "total_particles":
"Particle count",
23842 "lost_particles":
"Particles lost per step (search failures)",
23843 "lost_particles_cumulative":
"Cumulative particles lost (search failures)",
23844 "exited_particles":
"Particles that left the domain per step",
23845 "exited_particles_cumulative":
"Cumulative particles that left the domain",
23846 "migrated_particles":
"Migrated particles",
23847 "occupied_cells":
"Occupied cells",
23848 "load_imbalance":
"Particle load imbalance",
23849 "migration_passes":
"Particle migration passes",
23850 "pseudo_iterations":
"Pseudo-iterations to convergence",
23851 "newton_iterations":
"Newton iterations to convergence",
23852 "iterations":
"Linear iterations to convergence",
23853 "dtau":
"Pseudo-time step, Δτ",
23854 "dtau_after":
"Accepted pseudo-time step, Δτ",
23855 "cfl_eff":
"Effective pseudo-CFL",
23856 "cfl_eff_after":
"Accepted effective pseudo-CFL",
23857 "delta_norm":
"Momentum update norm, ‖ΔU‖",
23858 "delta_rel":
"Relative momentum update, ‖ΔU‖/‖ΔU₀‖",
23859 "residual_norm":
"Residual norm",
23860 "residual_rel":
"Relative residual norm",
23861 "trial_ratio":
"Pseudo-CFL trial ratio",
23862 "smoothed_ratio":
"Smoothed pseudo-CFL ratio",
23863 "unpreconditioned_norm":
"Unpreconditioned residual norm",
23864 "true_norm":
"True residual norm",
23865 "relative_norm":
"Relative residual norm",
23866 "calls":
"Calls per physical step",
23867 "step_time_s":
"Wall time per physical step (s)",
23868 "process_current_mb_max":
"Maximum resident memory (MiB)",
23869 "process_peak_mb_max":
"Peak resident memory (MiB)",
23870 "petsc_allocated_mb_max":
"PETSc allocated memory (MiB)",
23871 "petsc_peak_allocated_mb_max":
"Peak PETSc allocated memory (MiB)",
23872 "process_change_mb_max":
"Resident-memory change (MiB)",
23873 "u_abs_l2":
"Velocity L² error",
23874 "u_rel_l2":
"Relative velocity L² error",
23875 "p_abs_l2":
"Pressure L² error",
23876 "p_rel_l2":
"Relative pressure L² error",
23877 "mean_speed":
"Volume-mean speed",
23878 "spd_ref":
"Reference mean speed",
23879 "spd_abs":
"Absolute mean-speed drift",
23880 "spd_rel":
"Relative mean-speed drift",
23881 "mean_ke":
"Mean kinetic energy",
23882 "ke_ref":
"Reference mean kinetic energy",
23883 "ke_abs":
"Absolute kinetic-energy drift",
23884 "ke_rel":
"Relative kinetic-energy drift",
23885 "spd_win":
"Window-mean speed",
23886 "spd_win_prev":
"Previous window-mean speed",
23887 "spd_win_abs":
"Absolute window-mean speed drift",
23888 "spd_win_rel":
"Relative window-mean speed drift",
23889 "spd_rms_win":
"Window RMS mean speed",
23890 "spd_rms_abs":
"Absolute RMS speed drift",
23891 "spd_rms_rel":
"Relative RMS speed drift",
23892 "ke_win":
"Window-mean kinetic energy",
23893 "ke_win_prev":
"Previous window-mean kinetic energy",
23894 "ke_win_abs":
"Absolute window-mean kinetic-energy drift",
23895 "ke_win_rel":
"Relative window-mean kinetic-energy drift",
23896 "ke_rms_win":
"Window RMS kinetic energy",
23897 "ke_rms_abs":
"Absolute RMS kinetic-energy drift",
23898 "ke_rms_rel":
"Relative RMS kinetic-energy drift",
23899 "resolved_kinetic_energy":
"Resolved kinetic energy",
23900 "spectrum_total_energy":
"Integrated spectral energy",
23901 "parseval_residual":
"Parseval residual",
23902 "spectrum_peak_k":
"Peak wavenumber, kₚₑₐₖ",
23903 "zero_mode_energy":
"Zero-mode energy",
23904 "integral_length_scale":
"Integral length scale",
23905 "taylor_microscale":
"Taylor microscale",
23906 "dissipation_over_viscosity":
"Dissipation / kinematic viscosity",
23910_SUMMARY_PLOT_SOURCE_TITLES = {
23911 "continuity":
"Continuity",
23912 "particles":
"Particle transport",
23913 "momentum":
"Momentum solver",
23914 "poisson":
"Pressure Poisson solver",
23915 "profiling":
"Runtime profile",
23916 "memory":
"Runtime memory",
23917 "convergence":
"Solution convergence",
23918 "spectra":
"Turbulence spectrum",
23922_SUMMARY_ITERATION_HISTORY_FIELDS = {
23923 "dtau",
"dtau_after",
"cfl_eff",
"cfl_eff_after",
"delta_norm",
"delta_rel",
23924 "residual_norm",
"residual_rel",
"trial_ratio",
"smoothed_ratio",
23925 "unpreconditioned_norm",
"true_norm",
"relative_norm",
23929_SUMMARY_COUNT_FIELDS = {
23930 "total_particles",
"lost_particles",
"lost_particles_cumulative",
23931 "exited_particles",
"exited_particles_cumulative",
"migrated_particles",
"occupied_cells",
"migration_passes",
"calls",
23932 "pseudo_iterations",
"newton_iterations",
"iterations",
23938 @brief Convert one machine-oriented identifier into a readable plot label.
23939 @param[in] value Dotted path or snake-case identifier.
23940 @return Human-readable label.
23942 text = str(value).split(
".")[-1].replace(
"_",
" ").strip()
23944 "l2":
"L²",
"linf":
"L∞",
"msd":
"mean-squared displacement",
23945 "pct":
"percentage",
"p95":
"95th percentile",
"cfl":
"CFL",
23946 "ke":
"kinetic energy",
"rms":
"RMS",
23948 words = [replacements.get(word.lower(), word)
for word
in text.split()]
23949 return " ".join(words[:1]).capitalize() + (
" " +
" ".join(words[1:])
if len(words) > 1
else "")
23954 @brief Return the report-facing label for one logged scalar field.
23955 @param[in] field Logged scalar field name.
23956 @return Report-facing label.
23963 @brief Resolve a record's physical time from its artifact or copied case configuration.
23964 @param[in] context Summary context with copied case configuration.
23965 @param[in] record Collected plot record.
23966 @return Physical time, or None when it cannot be resolved.
23968 explicit = record.get(
"coordinates", {}).get(
"time")
23969 if explicit
is not None:
23970 return float(explicit)
23972 dt = float((context.get(
"case_cfg")
or {}).get(
"run_control", {}).get(
"dt_physical"))
23973 except (TypeError, ValueError):
23975 return float(record[
"step"]) * dt
if math.isfinite(dt)
and dt > 0.0
else None
23980 @brief Yields `(segment, row)` for each data line of one of the solver's CSV records.
23982 Every per-step file the solver writes shares one shape: a header (the first line that is
23983 not a comment) written once, data rows appended across the run, `#` comment lines, and a
23984 `# Continuation from step N` marker at each seam where a continuation resumed. A row with
23985 fewer fields than the header is skipped rather than guessed at; one with more keeps the
23986 header's columns, since columns are only ever appended. `segment` counts the
23987 continuations seen so far, so a caller can keep restarts as separate series instead of
23988 drawing a line across the seam.
23990 @param path CSV to read.
23991 @return Generator of `(segment_index, {column: text})` pairs.
23995 with open(path,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as handle:
23996 for raw_line
in handle:
24000 stripped = raw_line.strip()
24001 if not stripped
or stripped.startswith(
"#"):
24003 parts = [part.strip()
for part
in stripped.split(
",")]
24007 if len(parts) < len(header):
24009 yield segment, dict(zip(header, parts))
24014 @brief Return a solver record's CSV, or the text log an older run wrote instead.
24015 @param[in] log_dir Run log directory.
24016 @param[in] base File name without extension, such as `Continuity_Metrics`.
24017 @return Existing path, preferring `.csv`; None when neither exists.
24019 for extension
in (
".csv",
".log"):
24020 path = os.path.join(log_dir, base + extension)
24021 if os.path.isfile(path):
24028 @brief Return `(block, path)` for each per-block solver record, CSV preferred per block.
24029 @param[in] log_dir Run log directory.
24030 @param[in] bases File-name stems before `_Block_<n>`, newest naming first.
24031 @return Sorted `(block, path)` pairs.
24035 for extension
in (
".csv",
".log"):
24036 for path
in glob.glob(os.path.join(log_dir, f
"{base}_Block_*{extension}")):
24037 match = re.search(
r"_Block_(\d+)\.(csv|log)$", path)
24038 if match
and int(match.group(1))
not in chosen:
24039 chosen[int(match.group(1))] = path
24040 return sorted(chosen.items())
24045 @brief Return the continuity record's path and its `(segment, row)` pairs.
24046 @param[in] log_dir Run log directory.
24047 @return Path (or None) and rows keyed step, block, max_divergence, max_divergence_location,
24048 rhs_sum, flux_in, flux_out, net_flux.
24054 if path.endswith(
".csv"):
24057 if step
is None or block
is None:
24059 rows.append((segment, {
24060 "step": step,
"block": block,
24062 "max_divergence_location":
"([{}][{}][{}] = {})".format(
24063 row.get(
"max_divergence_k"), row.get(
"max_divergence_j"),
24064 row.get(
"max_divergence_i"), row.get(
"max_divergence_index")),
24072 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24077 line = raw_line.strip()
24078 if not line
or line.startswith(
"-")
or line.startswith(
"Timestep"):
24080 parts = [part.strip()
for part
in raw_line.split(
"|")]
24084 if step
is None or block
is None:
24086 rows.append((segment, {
24087 "step": step,
"block": block,
24089 "max_divergence_location": parts[3],
24100 @brief Return the per-step particle record's path and its `(segment, row)` pairs.
24102 `search_metrics.csv` carries the step's particle counts; a run that predates it in that
24103 role wrote `Particle_Metrics.log`, read when the CSV is absent.
24105 @param[in] log_dir Run log directory.
24106 @param[in] metrics_dir Run analysis metrics directory.
24107 @return Path (or None) and rows keyed step, total_particles, lost_particles,
24108 lost_particles_cumulative, migrated_particles, occupied_cells, load_imbalance,
24109 migration_passes, exited_particles, exited_particles_cumulative.
24112 csv_path = os.path.join(metrics_dir,
"search_metrics.csv")
if metrics_dir
else None
24113 if csv_path
and os.path.isfile(csv_path):
24118 rows.append((segment, {
24122 "lost_particles_cumulative":
_parse_int_loose(row.get(
"lost_cumulative")),
24128 "exited_particles_cumulative":
_parse_int_loose(row.get(
"exited_cumulative")),
24130 return csv_path, rows
24131 path = os.path.join(log_dir,
"Particle_Metrics.log")
24132 if not os.path.isfile(path):
24135 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24140 line = raw_line.strip()
24141 if not line
or line.startswith(
"-")
or line.startswith(
"Stage"):
24143 parts = [part.strip()
for part
in raw_line.split(
"|")]
24149 offset = 1
if len(parts) >= 9
else 0
24150 rows.append((segment, {
24154 "lost_particles_cumulative":
_parse_int_loose(parts[4])
if offset
else None,
24159 "exited_particles":
_parse_int_loose(parts[9])
if len(parts) >= 11
else None,
24160 "exited_particles_cumulative":
_parse_int_loose(parts[10])
if len(parts) >= 11
else None,
24165_LEGACY_PICARD_REGEX = re.compile(
24166 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\(k\):\s*(?P<pseudo_iter>\d+)\s*\|"
24167 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
24168 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
24169 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
24170 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
24171 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
24172 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
24173 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
24174 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
24180 @brief Return `(block, path, [(segment, row)])` for each Picard momentum history.
24181 @param[in] log_dir Run log directory.
24182 @return Rows keyed step, block, pseudo_iterations, dtau, cfl_eff, delta_norm, delta_rel,
24183 residual_norm, residual_rel, trial_ratio, smoothed_ratio, status, dtau_after,
24184 cfl_eff_after; dtau is solver time and cfl_eff = dtau * lambda_max.
24187 for block, path
in _block_family_paths(log_dir,
"Momentum_Solver_DualTime_Picard_Jameson_RK_History",
24188 "Momentum_Solver_Convergence_History"):
24190 if path.endswith(
".csv"):
24195 rows.append((segment, {
24196 "step": step,
"block": block,
24206 "status": row.get(
"status")
or None,
24212 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24217 match = _LEGACY_PICARD_REGEX.search(raw_line)
24220 rows.append((segment, {
24221 "step": int(match.group(
"step")),
"block": block,
24222 "pseudo_iterations": int(match.group(
"pseudo_iter")),
24225 "delta_norm": float(match.group(
"delta")),
24226 "delta_rel": float(match.group(
"delta_rel")),
24227 "residual_norm": float(match.group(
"resid")),
24228 "residual_rel": float(match.group(
"resid_rel")),
24231 "status": match.group(
"status"),
24235 out.append((block, path, rows))
24239_LEGACY_NEWTON_HISTORY_REGEX = re.compile(
24240 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
24241 r"\s*newton:\s*(?P<newton>\d+)\s*\|\s*nonlinear_norm:\s*(?P<norm>[-+0-9.eE]+)"
24243_LEGACY_NEWTON_SUMMARY_REGEX = re.compile(
24244 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
24245 r"\s*solver:\s*(?P<solver>[^|]+?)\s*\|"
24246 r"(?:\s*Jacobian:\s*[^|]+?\s*\|\s*Preconditioner:\s*[^|]+?\s*\|)?"
24247 r"\s*reason:\s*(?P<reason>\S+)\s*\|"
24248 r"\s*reason_code:\s*(?P<reason_code>-?\d+)\s*\|\s*newton:\s*(?P<newton>\d+)\s*\|"
24249 r"\s*evals:\s*(?P<evals>\d+)\s*\|\s*krylov:\s*(?P<krylov>\d+)\s*\|"
24250 r"\s*initial:\s*(?P<initial>[-+0-9.eE]+|unavailable)\s*\|"
24251 r"\s*final:\s*(?P<final>[-+0-9.eE]+)\s*\|\s*state:\s*(?P<state>\w+)"
24257 @brief Return `(block, path, [(segment, row)])` for each Newton iteration history.
24258 @param[in] log_dir Run log directory.
24259 @return Rows keyed step, block, newton_iterations, residual_norm.
24262 for block, path
in _block_family_paths(log_dir,
"Momentum_Solver_Newton_Krylov_History"):
24264 if path.endswith(
".csv"):
24267 if step
is not None:
24268 rows.append((segment, {
"step": step,
"block":
_parse_int_loose(row.get(
"block")),
24273 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24278 match = _LEGACY_NEWTON_HISTORY_REGEX.search(raw_line)
24280 rows.append((segment, {
"step": int(match.group(
"step")),
"block": int(match.group(
"block")),
24281 "newton_iterations": int(match.group(
"newton")),
24282 "residual_norm": float(match.group(
"norm"))}))
24283 out.append((block, path, rows))
24289 @brief Return `(block, path, [(segment, row)])` for each Newton per-step summary.
24290 @param[in] log_dir Run log directory.
24291 @return Rows keyed step, block, solver, reason, reason_code, newton_iterations,
24292 residual_evaluations, krylov_iterations, initial_norm, final_norm, state.
24295 for block, path
in _block_family_paths(log_dir,
"Momentum_Solver_Newton_Krylov_Summary"):
24297 if path.endswith(
".csv"):
24302 rows.append((segment, {
24304 "solver": row.get(
"solver"),
"reason": row.get(
"reason"),
24311 "state": row.get(
"state"),
24315 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24320 match = _LEGACY_NEWTON_SUMMARY_REGEX.search(raw_line)
24323 initial_text = match.group(
"initial")
24324 rows.append((segment, {
24325 "step": int(match.group(
"step")),
"block": int(match.group(
"block")),
24326 "solver": match.group(
"solver").strip(),
"reason": match.group(
"reason"),
24327 "reason_code": int(match.group(
"reason_code")),
24328 "newton_iterations": int(match.group(
"newton")),
24329 "residual_evaluations": int(match.group(
"evals")),
24330 "krylov_iterations": int(match.group(
"krylov")),
24331 "initial_norm":
None if initial_text ==
"unavailable" else float(initial_text),
24332 "final_norm": float(match.group(
"final")),
24333 "state": match.group(
"state"),
24335 out.append((block, path, rows))
24339_LEGACY_POISSON_REGEX = re.compile(
24340 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
24341 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
24342 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
24348 @brief Return `(block, path, [(segment, row)])` for each Poisson iteration history.
24349 @param[in] log_dir Run log directory.
24350 @return Rows keyed step, block, iterations, unpreconditioned_norm, true_norm, relative_norm;
24351 true_norm and relative_norm are None unless the run computed the true residual.
24356 if path.endswith(
".csv"):
24359 if step
is not None:
24360 rows.append((segment, {
"step": step,
"block":
_parse_int_loose(row.get(
"block")),
24367 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24372 match = _LEGACY_POISSON_REGEX.search(raw_line)
24374 rows.append((segment, {
"step": int(match.group(
"step")),
"block": int(match.group(
"block")),
24375 "iterations": int(match.group(
"iter")),
24376 "unpreconditioned_norm": float(match.group(
"unpre")),
24377 "true_norm": float(match.group(
"true")),
24379 out.append((block, path, rows))
24385 @brief Return the runtime memory record's `(segment, row)` pairs (CSV, or an older run's text log).
24386 @param[in] path Runtime memory log path as configured.
24387 @return Rows keyed step, event, process_current_mb_max, process_peak_mb_max,
24388 petsc_allocated_mb_max, petsc_peak_allocated_mb_max, process_change_mb_max, reason.
24391 if not path
or not os.path.isfile(path):
24393 keys = (
"process_current_mb_max",
"process_peak_mb_max",
"petsc_allocated_mb_max",
24394 "petsc_peak_allocated_mb_max",
"process_change_mb_max")
24395 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24398 if line.strip()
and not line.lstrip().startswith(
"#"):
24401 if first.startswith(
"step,"):
24406 rows.append((segment, {
"step": step,
"event": row.get(
"event"),
"reason": row.get(
"reason"),
24411 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24416 line = raw_line.strip()
24417 if not line
or line.startswith(
"#")
or line.startswith(
"Step"):
24419 parts = line.split()
24421 if len(parts) < 8
or step
is None:
24423 rows.append((segment, {
"step": step,
"event": parts[1],
"reason": parts[7],
24430_PLOT_ROW_IDENTITY_KEYS = (
"step",
"block")
24435 @brief Return a CSV cell as a float (written with a point or exponent), an int, or the text.
24436 @param[in] text Cell text.
24437 @return Parsed value.
24441 if float_val
is not None and (
"." in text
or "e" in text.lower()):
24443 if int_val
is not None:
24450 @brief Return the solution-convergence record's path and its `(segment, row)` pairs.
24451 @param[in] log_dir Run log directory.
24452 @return Path (or None) and rows with every column, numbers parsed and `mode` set.
24459 if path.endswith(
".csv"):
24464 values = {name:
_typed_csv_value(text)
for name, text
in row.items()
if name}
24465 values[
"step"] = step
24466 rows.append((segment, values))
24468 mode, col_names, segment =
None,
None, 0
24469 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
24471 line = raw_line.strip()
24477 if line.startswith(
"="):
24478 found = re.search(
r"\[mode:\s*([\w_]+)", line)
24480 mode = found.group(1)
24482 if line.startswith(
"-"):
24484 if col_names
is None:
24485 col_names = [p.strip()
for p
in raw_line.split(
"|")]
24487 parts = [p.strip()
for p
in raw_line.split(
"|")]
24488 step =
_parse_int_loose(parts[0])
if col_names[0] ==
"step" and len(parts) >= 4
else None
24491 values = {
"mode": mode}
24492 values.update({name:
_typed_csv_value(text)
for name, text
in zip(col_names, parts)
if name})
24493 values[
"step"] = step
24494 rows.append((segment, values))
24499 source_path: str, segment: int = 0, coordinates: dict =
None):
24501 @brief Append one numeric append-ordered record for summarize plotting.
24502 @param[out] records Destination record list.
24503 @param[in] source Qualified source prefix.
24504 @param[in] step Logged timestep.
24505 @param[in] line Human-readable line identity.
24506 @param[in] values Candidate field mapping.
24507 @param[in] source_path Source artifact path.
24508 @param[in] segment Zero-based continuation segment within the source artifact.
24509 @param[in] coordinates Optional independent variables carried by the source row.
24513 for key, value
in values.items()
24514 if isinstance(value, (int, float))
and not isinstance(value, bool)
24516 if step
is not None and numeric:
24522 "source_path": source_path,
24523 "segment": int(segment),
24525 key: value
for key, value
in (coordinates
or {}).items()
24526 if isinstance(value, (int, float))
and not isinstance(value, bool)
24533 @brief Return whether a log line starts a new continuation segment.
24534 @param[in] line Candidate raw or stripped log line.
24535 @return True for the shared continuation marker syntax.
24537 return bool(re.match(
r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
24542 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
24543 @param[in] context Summary context returned by `_build_summary_context()`.
24544 @return Append-ordered plot record list.
24547 log_dir = context[
"log_dir"]
24550 metrics_dir = context[
"metrics_dir"]
24553 for segment, row
in continuity_rows:
24555 records,
"continuity", row[
"step"], f
"block {row['block']}",
24556 {key: row[key]
for key
in (
"max_divergence",
"rhs_sum",
"flux_in",
"flux_out",
"net_flux")},
24557 continuity_path, segment,
24561 for segment, row
in particle_rows:
24563 records,
"particles", row[
"step"],
"particles",
24564 {key: value
for key, value
in row.items()
if key !=
"step"},
24565 particle_path, segment,
24570 for segment, row
in rows:
24572 records,
"momentum", row[
"step"], f
"block {block}",
24573 {key: value
for key, value
in row.items()
if key
not in _PLOT_ROW_IDENTITY_KEYS},
24575 coordinates={
"solver_iteration": row[
"pseudo_iterations"]},
24579 for segment, row
in rows:
24581 records,
"momentum", row[
"step"], f
"block {row['block']}",
24582 {
"newton_iterations": row[
"newton_iterations"],
"residual_norm": row[
"residual_norm"]},
24584 coordinates={
"solver_iteration": row[
"newton_iterations"]},
24588 for segment, row
in rows:
24590 records,
"poisson", row[
"step"], f
"block {row['block']}",
24591 {key: row[key]
for key
in (
"iterations",
"unpreconditioned_norm",
"true_norm",
"relative_norm")},
24593 coordinates={
"solver_iteration": row[
"iterations"]},
24598 les_path = os.path.join(metrics_dir,
"les_coefficient.csv")
24599 if os.path.isfile(les_path):
24621 wall_path = os.path.join(metrics_dir,
"wall_model.csv")
24622 if os.path.isfile(wall_path):
24637 wall_path, segment,
24642 driven_path = os.path.join(metrics_dir,
"driven_flow.csv")
24643 if os.path.isfile(driven_path):
24651 "bulk_velocity_correction":
_parse_float_loose(row.get(
"bulk_velocity_correction")),
24654 driven_path, segment,
24657 profiling_path = os.path.join(log_dir, PROFILING_TIMESTEP_FILE)
24658 if os.path.isfile(profiling_path):
24661 with open(profiling_path,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
24666 values = next(csv.reader([raw_line]))
24669 if columns
is None:
24672 row = dict(zip(columns, values))
24674 records,
"profiling",
_parse_int_loose(row.get(
"step")), row.get(
"function")
or "unknown",
24676 profiling_path, segment,
24679 memory_path =
_log_family_path(log_dir,
"Runtime_Memory")
or os.path.join(log_dir, RUNTIME_MEMORY_FILE)
24681 if row[
"event"]
in {
"Step",
"Post"}:
24683 records,
"memory", row[
"step"],
"memory",
24684 {key: value
for key, value
in row.items()
if key
not in _PLOT_ROW_IDENTITY_KEYS},
24685 memory_path, segment,
24689 for segment, row
in convergence_rows:
24691 records,
"convergence", row[
"step"],
"convergence",
24692 {name: value
for name, value
in row.items()
24693 if name
not in {
"step",
"time",
"mode",
"ref",
"ph",
"per",
"win"}},
24694 convergence_path, segment,
24695 coordinates={
"time": row.get(
"time")
if isinstance(row.get(
"time"), (int, float))
else None},
24705 @brief Collect the per-step scalar histories written by the spectra post stage.
24707 @details Each spectra task keeps its own history file, so one task becomes one
24708 plotted line and several tasks compare directly on the same axes.
24710 @param[in] context Summary context returned by `_build_summary_context()`.
24711 @return Append-ordered plot record list; empty when no spectra were measured.
24714 run_dir = context.get(
"run_dir")
24717 spectra_root = os.path.join(
24720 for root, _dirs, files
in os.walk(spectra_root):
24721 for filename
in sorted(files):
24722 if not filename.endswith(
"_history.csv"):
24724 history_path = os.path.join(root, filename)
24725 line = filename[: -len(
"_history.csv")]
24727 with open(history_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
24728 for row
in csv.DictReader(stream):
24733 for column
in POST_SPECTRA_SCALAR_COLUMNS:
24735 if parsed
is not None:
24736 values[column] = parsed
24739 records,
"spectra", step, line, values, history_path, 0,
24751 @brief Collect the profile histories the `profile_series` reducer wrote.
24753 @details Each `<prefix>_profile_<name>_history.csv` holds one row per step and one
24754 column per `<output column>.<functional>`, which becomes the series
24755 `profile.<column>.<functional>`, one line per history file. The functionals
24756 are the reducer's (`generators/reduce.gen profile-series`); this only reads.
24758 @param[in] context Summary context returned by `_build_summary_context()`.
24759 @return Append-ordered plot record list; empty when no history was written.
24762 run_dir = context.get(
"run_dir")
24765 root_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS[
"statistics"])
24766 for root, _dirs, files
in os.walk(root_dir):
24767 for filename
in sorted(files):
24768 if not re.match(
r"^.+_profile_.+_history\.csv$", filename):
24770 path = os.path.join(root, filename)
24771 line = os.path.relpath(path, root_dir)[: -len(
"_history.csv")]
24773 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
24774 for row
in csv.DictReader(stream):
24778 values = {key: value
for key, value
in values.items()
if value
is not None}
24779 if step
is not None and values:
24781 coordinates={
"time": time})
24789 @brief Build available qualified-series metadata from plot records.
24790 @param[in] records Append-ordered plot record list.
24791 @return Available series catalog.
24794 for record
in records:
24795 for field
in record[
"values"]:
24796 name = f
"{record['source']}.{field}"
24797 item = catalog.setdefault(name, {
"series": name,
"lines": {},
"source_paths": set(),
"sample_count": 0})
24798 item[
"lines"][record[
"line"]] = item[
"lines"].get(record[
"line"], 0) + 1
24799 item[
"source_paths"].add(record[
"source_path"])
24800 item[
"sample_count"] += 1
24804 "lines": [{
"label": label,
"sample_count": count}
for label, count
in sorted(item[
"lines"].items())],
24805 "source_paths": sorted(item[
"source_paths"]),
24807 for _, item
in sorted(catalog.items())
24813 @brief Select evenly distributed indices while always retaining both endpoints.
24814 @param[in] count Number of available ordered states.
24815 @param[in] maximum Maximum states to retain.
24816 @return Sorted unique zero-based indices.
24818 if count <= maximum:
24819 return list(range(count))
24820 return sorted({round(index * (count - 1) / (maximum - 1))
for index
in range(maximum)})
24824 linear_y: bool, output_path:
"str | None") -> dict:
24826 @brief Build a plot.gen request drawing representative measured spectra.
24828 @details A decaying flow has a different spectrum at every step, so states are
24829 never averaged. Up to six evenly spaced states are retained, including
24830 the first and last, to show evolution without an unreadable legend.
24832 @param[in] context Summary context returned by `_build_summary_context()`.
24833 @param[in] task Task basename, or a unique substring of one.
24834 @param[in] reference Overlay the staged initial-condition spectrum when available.
24835 @param[in] linear_y Force linear axes instead of the log-log default.
24836 @param[in] output_path Optional explicit output path.
24837 @return Versioned normalized plot request.
24838 @throws ValueError when no matching spectrum file exists.
24840 spectra_dir = os.path.join(
24841 context[
"run_dir"],
24845 if os.path.isdir(spectra_dir):
24850 reference_name = os.path.basename(INITIAL_CONDITION_SPECTRUM_RELPATH)
24851 for root, _dirs, files
in os.walk(spectra_dir):
24852 if os.path.relpath(root, spectra_dir).count(os.sep) > 0:
24855 if (name.endswith(
".csv")
and not name.endswith(
"_history.csv")
24856 and name != reference_name):
24857 candidates.append(os.path.relpath(os.path.join(root, name), spectra_dir))
24861 "No spectra were found for this run. Measure them with a post.yml that defines "
24862 "spectra tasks: 'picurv run --post-process --only spectra --run-dir <run> --post <post.yml>'."
24864 matches = [name
for name
in candidates
if task
in name]
if task
else candidates
24865 if len(matches) != 1:
24866 available =
", ".join(name[: -len(
".csv")]
for name
in candidates)
24868 f
"Spectrum selector {task!r} matched {len(matches)} files. Available: {available}."
24870 spectrum_path = os.path.join(spectra_dir, matches[0])
24871 if "plane_spectrum" in matches[0]:
24872 raise ValueError(
"Plane spectra have two wavenumber axes; use the spectrum CSV for a 2D plot.")
24876 with open(spectrum_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
24877 for row
in csv.DictReader(stream):
24881 if step
is None or wavenumber
is None or energy
is None:
24884 if wavenumber <= 0.0
or (
not linear_y
and energy <= 0.0):
24886 by_step.setdefault(step, []).append([wavenumber, energy])
24889 raise ValueError(f
"{os.path.relpath(spectrum_path)} carries no plottable spectrum rows.")
24893 reference_path = os.path.join(context[
"run_dir"], INITIAL_CONDITION_SPECTRUM_RELPATH)
24894 if os.path.isfile(reference_path):
24896 with open(reference_path,
"r", encoding=
"utf-8", errors=
"replace")
as stream:
24897 for row
in csv.DictReader(stream):
24900 if wavenumber
is None or energy
is None:
24902 if wavenumber <= 0.0
or (
not linear_y
and energy <= 0.0):
24904 points.append([wavenumber, energy])
24907 "label":
"Initial condition",
"points": sorted(points),
24908 "role":
"reference",
"color":
"#333333",
"line_style":
"--",
24911 ordered_steps = sorted(by_step)
24913 spectrum_colors = (
"#440154",
"#414487",
"#2A788E",
"#22A884",
"#7AD151",
"#DCE319")
24914 for index, step
in enumerate(selected_steps):
24915 time = times.get(step)
24916 label = f
"Step {step}" if time
is None else f
"t = {time:g} (step {step})"
24919 "points": sorted(by_step[step]),
24920 "role":
"latest" if step == ordered_steps[-1]
else "snapshot",
24921 "color": spectrum_colors[round(index * (len(spectrum_colors) - 1) / max(1, len(selected_steps) - 1))],
24922 "line_width": 2.4
if step == ordered_steps[-1]
else 1.65,
24925 name = os.path.basename(matches[0])[: -len(
".csv")]
24926 fallback = os.path.join(
24927 context[
"run_dir"], CANONICAL_RUN_PATHS[
"plots"], f
"{name}.png"
24929 task_match = re.search(
r"_(?P<field>[^_]+)_block(?P<block>\d+)_(?P<symbol>[^_]+)$", name)
24933 f
"Field {task_match.group('field')} · block {int(task_match.group('block'))} "
24934 f
"· {task_match.group('symbol')} wavenumber"
24937 "schema_version": 1,
24938 "plot_type":
"spectrum",
24940 "title":
"Turbulent kinetic-energy spectrum",
24941 "subtitle": subtitle,
24942 "x_label":
"Wavenumber, k",
24943 "y_label":
"Energy spectrum, E(k)",
24944 "x_scale":
"linear" if linear_y
else "log",
24945 "y_scale":
"linear" if linear_y
else "log",
24946 "legend_title":
"Snapshot",
24947 "show_markers":
False,
24949 "mode":
"representative",
"last":
None,
24950 "available_steps": len(ordered_steps),
"selected_steps": selected_steps,
24953 "output_path": os.path.abspath(output_path)
if output_path
else fallback,
24959 @brief Build one normalized plot.gen request from collected summarize records.
24960 @param[in] context Summary context returned by `_build_summary_context()`.
24961 @param[in] records Append-ordered plot record list.
24962 @param[in] series Qualified series name.
24963 @param[in] last_n Optional last-N records per plotted line.
24964 @param[in] linear_y Whether to force linear scaling.
24965 @param[in] output_path Optional explicit output path.
24966 @return Versioned normalized plot request.
24968 source, separator, field = series.partition(
".")
24970 raise ValueError(
"plot series must be qualified as '<source>.<field>'")
24971 matching = [record
for record
in records
if record[
"source"] == source
and field
in record[
"values"]]
24973 raise ValueError(f
"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
24974 latest_segments = {}
24975 for record
in matching:
24976 source_path = record[
"source_path"]
24977 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get(
"segment", 0))
24979 record
for record
in matching
24980 if record.get(
"segment", 0) == latest_segments[record[
"source_path"]]
24982 is_iteration_history = (
24983 source
in {
"momentum",
"poisson"}
and field
in _SUMMARY_ITERATION_HISTORY_FIELDS
24984 and any(
"solver_iteration" in record.get(
"coordinates", {})
for record
in matching)
24987 selected_steps = set()
24988 if is_iteration_history:
24990 for record
in matching:
24991 iteration = record.get(
"coordinates", {}).get(
"solver_iteration")
24992 if iteration
is None:
24994 histories.setdefault((record[
"source_path"], record[
"line"]), []).append(record)
24995 for (_source_path, label), history
in histories.items():
24996 latest_step = max(record[
"step"]
for record
in history)
24997 selected_steps.add(latest_step)
24999 [record[
"coordinates"][
"solver_iteration"], record[
"values"][field]]
25000 for record
in history
if record[
"step"] == latest_step
25002 if last_n
is not None:
25003 points = points[-last_n:]
25004 grouped.setdefault(f
"{label} · step {latest_step}", []).extend(points)
25005 x_label =
"Nonlinear iteration" if source ==
"momentum" else "Linear iteration"
25009 for record
in matching:
25011 grouped.setdefault(record[
"line"], []).append([x_value, record[
"values"][field]])
25014 if field
in {
"pseudo_iterations",
"newton_iterations",
"iterations"}:
25016 label: [
list(item)
for item
in {
25017 point[0]: max(candidate[1]
for candidate
in points
if candidate[0] == point[0])
25018 for point
in points
25020 for label, points
in grouped.items()
25022 for label, points
in grouped.items():
25023 points.sort(key=
lambda point: point[0])
25024 if last_n
is not None:
25025 grouped[label] = points[-last_n:]
25026 x_label =
"Physical time" if uses_physical_time
else "Physical timestep"
25027 x_kind =
"continuous" if uses_physical_time
else "integer"
25029 grouped = {label: points
for label, points
in grouped.items()
if points}
25031 raise ValueError(f
"Plot series '{series}' has no plottable points in the selected window.")
25032 if is_iteration_history
and len(selected_steps) == 1:
25034 label.rsplit(
" · step ", 1)[0]: points
25035 for label, points
in grouped.items()
25037 all_values = [point[1]
for points
in grouped.values()
for point
in points]
25038 use_log =
not linear_y
and field
in _SUMMARY_PLOT_LOG_SCALE_FIELDS
and all(value > 0
for value
in all_values)
25039 window_token = f
"last-{last_n}" if last_n
is not None else "full"
25040 safe_series = re.sub(
r"[^A-Za-z0-9_.-]+",
"_", series)
25041 fallback = os.path.join(
25042 context[
"run_dir"], CANONICAL_RUN_PATHS[
"plots"],
25043 f
"{safe_series}_{window_token}.png",
25047 title = f
"{source_title}: {field_label}"
25048 if is_iteration_history:
25049 title +=
" convergence"
25052 ordered = sorted(selected_steps)
25053 subtitle_bits.append(
25054 f
"Latest physical step {ordered[0]}" if len(ordered) == 1
25055 else f
"Latest available physical steps {ordered[0]}–{ordered[-1]}"
25057 if last_n
is not None:
25058 subtitle_bits.append(f
"Last {last_n} samples per curve")
25060 "continuity":
"Block",
25061 "momentum":
"Block",
25062 "poisson":
"Block",
25063 "profiling":
"Function",
25064 "spectra":
"Spectrum task",
25065 }.get(source,
"Series")
25066 only_label = next(iter(grouped))
if len(grouped) == 1
else None
25067 show_legend = len(grouped) > 1
or bool(
25068 only_label
and (only_label.lower().startswith(
"block ")
or source ==
"profiling")
25071 "schema_version": 1,
25072 "plot_type":
"iteration_history" if is_iteration_history
else "time_history",
25075 "subtitle":
" · ".join(subtitle_bits)
or None,
25076 "x_label": x_label,
25078 "y_label": field_label,
25079 "y_scale":
"log" if use_log
else "linear",
25080 "include_zero_y":
not use_log
and all(value >= 0.0
for value
in all_values),
25081 "show_markers":
not is_iteration_history,
25082 "show_legend": show_legend,
25083 "legend_title": legend_title,
25084 "window": {
"mode":
"last" if last_n
is not None else "full",
"last": last_n},
25085 "lines": [{
"label": label,
"points": points}
for label, points
in sorted(grouped.items())],
25086 "output_path": os.path.abspath(output_path)
if output_path
else None,
25087 "fallback_output_path": fallback,
25093 @brief Render available summarize plot-series metadata.
25094 @param[in] catalog Available series catalog.
25095 @param[in] output_format Text or JSON output format.
25097 if output_format ==
"json":
25098 print(json.dumps({
"available_series": catalog}, indent=2, sort_keys=
True))
25100 print(
"\nAVAILABLE PLOT SERIES")
25102 for item
in catalog:
25103 labels =
", ".join(line[
"label"]
for line
in item[
"lines"])
25104 print(f
" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
25105 print(f
" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
25111 "continuity": (
"log_dir",
"Continuity_Metrics.csv"),
25112 "convergence": (
"log_dir",
"solution_convergence.csv"),
25113 "memory": (
"log_dir", RUNTIME_MEMORY_FILE),
25114 "profiling": (
"log_dir",
"ProfilingSummary_Solver.csv"),
25115 "profiling-post": (
"log_dir",
"ProfilingSummary_PostProcessor.csv"),
25116 "profiling-steps": (
"log_dir", PROFILING_TIMESTEP_FILE),
25117 "picard": (
"log_dir",
"Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_{block}.csv"),
25118 "newton": (
"log_dir",
"Momentum_Solver_Newton_Krylov_Summary_Block_{block}.csv"),
25119 "newton-history": (
"log_dir",
"Momentum_Solver_Newton_Krylov_History_Block_{block}.csv"),
25120 "newton-linear": (
"log_dir",
"Momentum_Solver_Newton_Krylov_Linear_History_Block_{block}.csv"),
25121 "poisson": (
"log_dir",
"Poisson_Solver_Convergence_History_Block_{block}.csv"),
25122 "search": (
"metrics_dir",
"search_metrics.csv"),
25123 "scatter": (
"metrics_dir",
"scatter_metrics.csv"),
25124 "interpolation-error": (
"metrics_dir",
"interpolation_error.csv"),
25125 "les": (
"metrics_dir",
"les_coefficient.csv"),
25126 "wall-model": (
"metrics_dir",
"wall_model.csv"),
25127 "driven-flow": (
"metrics_dir",
"driven_flow.csv"),
25128 "particle-initial-fields": (
"metrics_dir",
"particle_initial_fields.csv"),
25134 @brief Resolve a `summarize --table` name to the CSV it prints.
25135 @param[in] context Summary context returned by `_build_summary_context()`.
25136 @param[in] name Table name from SUMMARY_TABLES, optionally `:<block>`.
25137 @return Path to the CSV (it may not exist).
25138 @throws ValueError When the name is unknown or the block is not an integer.
25140 base, _, block = name.partition(
":")
25141 if base
not in SUMMARY_TABLES:
25142 raise ValueError(f
"Unknown table '{base}'. Known tables: {', '.join(sorted(SUMMARY_TABLES))}.")
25143 if block
and not block.isdigit():
25144 raise ValueError(f
"Table block '{block}' must be a non-negative integer.")
25145 directory_key, file_name = SUMMARY_TABLES[base]
25146 if "{block}" in file_name:
25147 file_name = file_name.format(block=int(block
or 0))
25149 raise ValueError(f
"Table '{base}' is not per-block; drop ':{block}'.")
25150 return os.path.join(context[directory_key], file_name)
25155 @brief Print the `summarize --table` names whose CSV this run has, with row counts.
25156 @param[in] context Summary context returned by `_build_summary_context()`.
25157 @param[in] output_format Text or JSON output format.
25160 for name
in sorted(SUMMARY_TABLES):
25162 if "{block}" in (SUMMARY_TABLES[name][1]
or ""):
25163 pattern = os.path.join(context[
"log_dir"], SUMMARY_TABLES[name][1].format(block=
"*"))
25164 blocks = sorted(int(re.search(
r"_Block_(\d+)\.csv$", path).group(1))
for path
in glob.glob(pattern))
25165 names = [name
if b == 0
else f
"{name}:{b}" for b
in blocks]
25166 for table
in names:
25168 if os.path.isfile(path):
25170 columns = len(rows[0][1])
if rows
else 0
25171 found.append({
"table": table,
"rows": len(rows),
"columns": columns,
"path": path})
25172 if output_format ==
"json":
25173 print(json.dumps({
"available_tables": found}, indent=2, sort_keys=
True))
25176 raise ValueError(
"No CSV records were found in the run (a run older than the CSV records keeps text logs).")
25177 print(
"\nAVAILABLE TABLES")
25180 print(f
" {item['table']:<26} rows={item['rows']:<7} columns={item['columns']:<3} {os.path.relpath(item['path'])}")
25185 @brief Print the last rows of one of the run's CSV records as aligned columns (or JSON).
25186 @param[in] context Summary context returned by `_build_summary_context()`.
25187 @param[in] name Table name (`summarize --list-tables`).
25188 @param[in] last_n Rows to show from the end; None shows the last 20.
25189 @param[in] columns Comma-separated column names to keep, or None for all.
25190 @param[in] output_format Text or JSON output format.
25191 @throws ValueError When the table is unknown, absent, or a requested column is missing.
25194 if not os.path.isfile(path):
25195 raise ValueError(f
"Table '{name}' has no file at {os.path.relpath(path)}; --list-tables names the ones this run has.")
25198 raise ValueError(f
"Table '{name}' has no data rows yet.")
25199 header =
list(rows[0][1].keys())
25201 wanted = [column.strip()
for column
in columns.split(
",")
if column.strip()]
25202 missing = [column
for column
in wanted
if column
not in header]
25204 raise ValueError(f
"Table '{name}' has no column(s) {', '.join(missing)}; its columns are: {', '.join(header)}.")
25205 keep = ([header[0]]
if header[0]
not in wanted
else []) + wanted
25208 shown = rows[-(last_n
or 20):]
25209 if output_format ==
"json":
25210 print(json.dumps({
"table": name,
"path": path,
"columns": keep,
25211 "rows": [{
"segment": segment, **{key: row.get(key)
for key
in keep}}
for segment, row
in shown]},
25214 widths = {key: max(len(key), *(len(row.get(key)
or "")
for _, row
in shown))
for key
in keep}
25215 print(f
"\n{name}: {os.path.relpath(path)} (last {len(shown)} of {len(rows)} rows)")
25216 print(
" ".join(key.rjust(widths[key])
for key
in keep))
25217 print(
" ".join(
"-" * widths[key]
for key
in keep))
25218 previous_segment = shown[0][0]
25219 for segment, row
in shown:
25220 if segment != previous_segment:
25221 print(f
"# continuation segment {segment}")
25222 previous_segment = segment
25223 print(
" ".join((row.get(key)
or "").rjust(widths[key])
for key
in keep))
25228 @brief Invoke standalone plot.gen with one normalized request over stdin.
25229 @param[in] request Versioned normalized plot request.
25231 plotgen_path = os.path.join(GENERATORS_PATH,
"plot.gen")
25232 if not os.path.isfile(plotgen_path):
25233 raise ValueError(f
"plot.gen script not found: {plotgen_path}")
25234 result = subprocess.run(
25235 [sys.executable, plotgen_path,
"--input",
"-"],
25236 input=json.dumps(request),
25238 capture_output=
True,
25242 print(result.stdout.rstrip())
25243 if result.returncode != 0:
25244 details = (result.stderr
or result.stdout
or "unknown plotting error").strip()
25245 if result.returncode == 3:
25247 raise ValueError(f
"plot.gen failed with exit code {result.returncode}: {details}")
25252 @brief Build and render a read-only health summary for a run step.
25253 @param[in] args Command-line style argument list supplied to the function.
25255 if args.step
is not None and args.step < 0:
25257 if args.snapshot_rows < 1:
25259 plot_series = getattr(args,
"plot_series",
None)
25260 list_plot_series = bool(getattr(args,
"list_plot_series",
False))
25261 plot_spectrum = getattr(args,
"plot_spectrum",
None)
25262 last_n = getattr(args,
"last_n",
None)
25263 plot_output = getattr(args,
"plot_output",
None)
25264 linear_y = bool(getattr(args,
"linear_y",
False))
25265 table = getattr(args,
"table",
None)
25266 list_tables = bool(getattr(args,
"list_tables",
False))
25267 table_columns = getattr(args,
"table_columns",
None)
25268 plot_mode = bool(plot_series
or list_plot_series
or plot_spectrum
is not None or table
or list_tables)
25269 existing_selectors = any(
25271 getattr(args,
"overview",
False),
25272 getattr(args,
"case",
False),
25273 getattr(args,
"solver",
False),
25274 getattr(args,
"monitor",
False),
25275 args.step
is not None,
25276 getattr(args,
"latest",
False),
25277 getattr(args,
"max_step",
False),
25280 if plot_mode
and existing_selectors:
25281 fail_cli_usage(
"Plot discovery and --plot cannot be combined with config or selected-step selectors.")
25282 if not plot_series
and not table
and last_n
is not None:
25284 if table_columns
and not table:
25286 if not plot_series
and plot_spectrum
is None and (plot_output
or linear_y):
25287 fail_cli_usage(
"--plot-output and --linear-y require --plot or --plot-spectrum.")
25288 if last_n
is not None and last_n < 1:
25290 if plot_series
and args.output_format ==
"json":
25291 fail_cli_usage(
"--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
25292 if table
or list_tables:
25299 except ValueError
as exc:
25301 ERROR_CODE_CFG_INVALID_VALUE,
25303 file_path=context[
"log_dir"],
25305 hint=
"--list-tables names the CSV records this run has.",
25314 if list_plot_series:
25316 raise ValueError(
"No plottable scalar histories were found in the run logs.")
25319 if plot_spectrum
is not None:
25321 context, plot_spectrum,
True, linear_y, plot_output
25325 context, records, plot_series, last_n, linear_y, plot_output
25329 except PlotDependencyError
as exc:
25331 ERROR_CODE_DEPENDENCY_MISSING,
25333 file_path=sys.executable,
25337 except ValueError
as exc:
25339 ERROR_CODE_CFG_INVALID_VALUE,
25341 file_path=context[
"log_dir"],
25343 hint=(
"--list-plot-series names the scalar histories this run recorded."
25344 if plot_spectrum
is None else
25345 "Spectra come from the spectra tasks of a post.yml (see the post-processing reference)."),
25349 selected_configs = {
25351 for name
in (
"case",
"solver",
"monitor")
25352 if bool(getattr(args, name,
False))
25354 if getattr(args,
"overview",
False):
25355 selected_configs.update({
"case",
"solver",
"monitor"})
25356 explicit_health = args.step
is not None or bool(getattr(args,
"latest",
False))
or bool(getattr(args,
"max_step",
False))
25357 health_requested = explicit_health
or (
not selected_configs
and not getattr(args,
"overview",
False))
25360 combined = {
"storage": storage_state_summary(args.run_dir)}
25361 if selected_configs
or getattr(args,
"overview",
False):
25363 if getattr(args,
"overview",
False):
25365 combined[
"configuration"] = {}
25367 "case": _build_case_overview,
25368 "solver": _build_solver_overview,
25369 "monitor": _build_monitor_overview,
25371 for name
in (
"case",
"solver",
"monitor"):
25372 if name
in selected_configs:
25374 combined[
"configuration"][name] = builders[name](context)
25375 except (KeyError, TypeError, ValueError, ZeroDivisionError)
as exc:
25377 ERROR_CODE_CFG_INVALID_VALUE,
25379 file_path=context[
"config_paths"][name],
25380 message=f
"Could not summarize copied {name}.yml: {exc}",
25384 if not health_requested:
25385 combined[
"_health_requested"] =
False
25389 requested_step = args.step
25390 if requested_step
is None and getattr(args,
"latest",
False):
25391 requested_step =
None
25392 selection_mode =
"max_step" if getattr(args,
"max_step",
False)
else "latest"
25395 step=requested_step,
25396 snapshot_rows=args.snapshot_rows,
25397 selection_mode=selection_mode,
25399 if set(combined) == {
"storage"}:
25400 combined = {**health_payload, **combined,
"_health_requested":
True}
25403 combined = {**health_payload, **combined,
"_health_requested":
True}
25409 @brief Resolve a run/study submission target from explicit directory flags.
25410 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
25411 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
25412 @return Value returned by `_resolve_submission_target()`.
25414 has_run_dir = bool(run_dir)
25415 has_study_dir = bool(study_dir)
25416 if has_run_dir == has_study_dir:
25417 fail_cli_usage(
"submit requires exactly one of --run-dir or --study-dir.")
25419 target_kind =
"run" if has_run_dir
else "study"
25420 target_key =
"run_dir" if target_kind ==
"run" else "study_dir"
25421 root_dir = os.path.abspath(run_dir
if has_run_dir
else study_dir)
25422 if not os.path.isdir(root_dir):
25424 ERROR_CODE_CFG_FILE_NOT_FOUND,
25426 file_path=root_dir,
25427 message=f
"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
25431 scheduler_dir = os.path.join(root_dir,
"scheduler")
25432 submission_path = os.path.join(scheduler_dir,
"submission.json")
25433 continuation =
False
25434 if target_kind ==
"study":
25437 continue_path = os.path.join(scheduler_dir,
"submission_continue.json")
25438 if os.path.isfile(continue_path)
and (
25439 not os.path.isfile(submission_path)
25440 or os.path.getmtime(continue_path) >= os.path.getmtime(submission_path)):
25441 submission_path, continuation = continue_path,
True
25443 if not isinstance(submission_meta, dict):
25445 ERROR_CODE_CFG_FILE_NOT_FOUND,
25446 key=
"scheduler.submission",
25447 file_path=submission_path,
25448 message=
"Target directory does not contain scheduler submission metadata.",
25449 hint=
"Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
25453 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
25454 if launch_mode ==
"local" and target_kind !=
"run":
25456 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25457 key=
"scheduler.launch_mode",
25458 file_path=submission_path,
25459 message=
"Local staged submission is supported for run directories only.",
25460 hint=
"Use --run-dir for local staged execution; study submit remains Slurm-only.",
25463 if launch_mode
not in {
"slurm",
"local"}:
25465 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25466 key=
"scheduler.launch_mode",
25467 file_path=submission_path,
25468 message=f
"Target launch_mode={launch_mode or 'unknown'} is not supported.",
25469 hint=
"Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
25473 if target_kind ==
"run":
25475 "solve": os.path.join(scheduler_dir,
"solver.sbatch"),
25476 "post-process": os.path.join(scheduler_dir,
"post.sbatch"),
25478 display_label =
"Run directory"
25479 manifest_path =
None
25481 stage_keys = ({
"solve":
"solver_continue_array",
"post-process":
"post_continue_array",
25482 "metrics":
"metrics_aggregate"}
if continuation
else
25483 {
"solve":
"solver_array",
"post-process":
"post_array",
25484 "metrics":
"metrics_aggregate"})
25485 default_scripts = ({
"solve":
"solver_continue_array.sbatch",
"post-process":
"post_continue_array.sbatch",
25486 "metrics":
"metrics_continue_aggregate.sbatch"}
if continuation
else
25487 {
"solve":
"solver_array.sbatch",
"post-process":
"post_array.sbatch",
25488 "metrics":
"metrics_aggregate.sbatch"})
25490 stage: ((submission_meta.get(key)
or {}).get(
"script")
25491 or os.path.join(scheduler_dir, default_scripts[stage]))
25492 for stage, key
in stage_keys.items()
25494 display_label =
"Study continuation" if continuation
else "Study directory"
25495 manifest_path = os.path.join(root_dir,
"study_manifest.json")
25498 "target_kind": target_kind,
25499 "target_key": target_key,
25500 "root_dir": root_dir,
25501 "scheduler_dir": scheduler_dir,
25502 "submission_path": submission_path,
25503 "submission_meta": submission_meta,
25504 "launch_mode": launch_mode,
25505 "script_map": script_map,
25506 "display_label": display_label,
25507 "manifest_path": manifest_path,
25508 "continuation": continuation,
25509 "stage_keys": stage_keys
if target_kind ==
"study" else None,
25515 @brief Return stored metadata for one staged submission target.
25516 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
25517 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
25518 @return Value returned by `_get_submission_stage_metadata()`.
25520 submission_meta = target_context[
"submission_meta"]
25521 if target_context[
"target_kind"] ==
"run":
25522 stages = submission_meta.get(
"stages", {})
25523 if not isinstance(stages, dict):
25525 stage_meta = stages.get(stage_name)
25526 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
25528 stage_meta = submission_meta.get(target_context[
"stage_keys"][stage_name])
25529 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
25534 @brief Return stage names explicitly recorded in scheduler submission metadata.
25535 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
25536 @return Value returned by `_get_recorded_submission_stages()`.
25538 submission_meta = target_context[
"submission_meta"]
25540 if target_context[
"target_kind"] ==
"run":
25541 stages = submission_meta.get(
"stages", {})
25542 if isinstance(stages, dict):
25543 for stage_name
in [
"solve",
"post-process"]:
25544 if isinstance(stages.get(stage_name), dict):
25545 recorded.append(stage_name)
25548 for stage_name
in (
"solve",
"post-process"):
25549 if isinstance(submission_meta.get(target_context[
"stage_keys"][stage_name]), dict):
25550 recorded.append(stage_name)
25556 @brief Build an actionable hint for requested submit stages missing from metadata.
25557 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
25558 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
25559 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
25560 @return Value returned by `_build_submit_missing_stage_hint()`.
25563 recorded_set = set(recorded_stages)
25564 selected_set = set(selected_stages)
25565 solve_stage_command = (
25566 "picurv run --solve ... --no-submit"
25567 if target_context[
"target_kind"] ==
"run"
25568 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25570 post_stage_command = (
25571 "picurv run --post-process --post <post.yml> ... --no-submit"
25572 if target_context[
"target_kind"] ==
"run"
25573 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25575 solve_post_command = (
25576 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
25577 if target_context[
"target_kind"] ==
"run"
25578 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25581 if requested_stage ==
"all" and not recorded_set:
25583 "--stage all submits every staged stage, but submission metadata records none. "
25584 f
"Re-stage with `{solve_stage_command}` or `{post_stage_command}`."
25587 if selected_set == {
"solve"}
and "solve" not in recorded_set:
25589 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
25590 f
"Re-stage with `{solve_stage_command}`."
25592 if selected_set == {
"post-process"}
and "post-process" not in recorded_set:
25594 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
25595 f
"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
25598 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
25603 @brief Persist one stage's metadata back into the submission payload.
25604 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
25605 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
25606 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
25608 submission_meta = target_context[
"submission_meta"]
25609 if target_context[
"target_kind"] ==
"run":
25610 stages = submission_meta.get(
"stages")
25611 if not isinstance(stages, dict):
25613 submission_meta[
"stages"] = stages
25614 stages[stage_name] = stage_meta
25617 submission_meta[target_context[
"stage_keys"][stage_name]] = stage_meta
25622 @brief Write updated submission metadata back to disk.
25623 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
25625 write_json_file(target_context[
"submission_path"], target_context[
"submission_meta"])
25627 manifest_path = target_context.get(
"manifest_path")
25628 if manifest_path
and os.path.isfile(manifest_path):
25630 if isinstance(manifest_payload, dict):
25631 if target_context.get(
"continuation"):
25632 manifest_payload.setdefault(
"continuation", {})[
"submission"] = target_context[
"submission_meta"]
25634 manifest_payload[
"submission"] = target_context[
"submission_meta"]
25640 @brief Read run-owned directory values from a staged control file.
25642 @details Tokenizes with `shlex` because PETSc's options-file parser treats a
25643 double-quoted span as a single token. Splitting on whitespace would read
25644 `-log_dir "/tmp/VICTIM DIR"` as the value `"/tmp/VICTIM`, which looks
25645 relative and contained while PETSc would use the absolute path.
25647 Malformed quoting is reported rather than skipped: a line the parser
25648 cannot interpret is exactly the case where preflight must not assume the
25650 @param[in] control_path Path to a generated `.control` file.
25651 @return Tuple of (values, parse_errors).
25655 "-output_dir":
"output",
25656 "-restart_dir":
"restart",
25657 "-analysis_dir":
"analysis",
25660 parse_errors: list = []
25663 except OSError
as exc:
25664 return values, [f
"could not be read ({exc})"]
25665 for number, line
in enumerate(lines, start=1):
25666 if not line.strip():
25669 tokens = shlex.split(line, comments=
True)
25670 except ValueError
as exc:
25671 parse_errors.append(
25672 f
"line {number} has malformed quoting and cannot be interpreted ({exc}); "
25673 f
"refusing to assume it is safe"
25676 if tokens
and tokens[0]
in RESERVED_INDIRECTION_FLAGS:
25677 parse_errors.append(
25678 f
"line {number} uses '{tokens[0]}', which PETSc expands itself; its contents "
25679 f
"cannot be checked here and could set a run directory. Re-stage without it"
25682 if len(tokens) >= 2
and tokens[0]
in flag_to_key:
25683 values[flag_to_key[tokens[0]]] = tokens[1]
25684 return values, parse_errors
25689 @brief Every config directory under a run or study root that may hold a control file.
25691 @details A study keeps its controls under `cases/<member>/config`, not at the study
25692 root, so a preflight that only looked at `<root>/config` was empty for every
25693 study. This walks the tree so members and nested runs are covered.
25694 @param[in] root_dir Run or study directory.
25695 @return Sorted config directory paths.
25698 root = os.path.abspath(root_dir)
25699 direct = os.path.join(root,
"config")
25700 if os.path.isdir(direct):
25702 for current, dirnames, _
in os.walk(root):
25703 dirnames[:] = [d
for d
in dirnames
if not os.path.islink(os.path.join(current, d))]
25704 if os.path.basename(current) ==
"config" and glob.glob(os.path.join(current,
"*.control")):
25706 return sorted(found)
25711 @brief Re-check run-directory safety against an already-staged run or study.
25713 @details Staging validates the configuration it is given, but a staged run can be
25714 edited, or produced by an older version, before submission. This re-reads the
25715 effective staged control files - across study members and nested runs - and
25716 applies the same rules as configuration validation, plus a physical
25717 containment check that a symlink cannot slip past.
25718 @param[in] root_dir Run or study directory being submitted.
25719 @return Tuple of (errors, warnings).
25722 warnings: list = []
25724 if not config_dirs:
25725 return errors, warnings
25727 "log": CANONICAL_RUN_PATHS[
"logs"],
25728 "output": CANONICAL_RUN_PATHS[
"output"],
25729 "restart": CANONICAL_RUN_PATHS[
"restart"],
25730 "analysis": CANONICAL_RUN_PATHS[
"metrics"],
25732 for config_dir
in config_dirs:
25733 run_root = os.path.dirname(config_dir)
25734 for control
in sorted(glob.glob(os.path.join(config_dir,
"*.control"))):
25736 label = os.path.relpath(control)
25737 errors.extend(f
" {label}: {message}" for message
in parse_errors)
25740 for key, expected
in canonical.items():
25741 actual = staged.get(key)
25744 f
" {label}: -{key}_dir is {actual!r}; the canonical value is "
25745 f
"{expected!r}. Re-stage the run instead of editing its path flags."
25749 effective,
False, explicit=set(staged)
25751 errors.extend(f
" {label}: {message}" for message
in control_errors)
25752 warnings.extend(f
" {label}: {message}" for message
in control_warnings)
25754 errors.append(f
" {label}: {message}")
25755 return errors, warnings
25760 @brief Read a text file into a list of lines.
25761 @param[in] path File to read.
25762 @return List of lines.
25764 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as handle:
25765 return handle.readlines()
25770 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
25771 @param[in] args Command-line style argument list supplied to the function.
25773 if getattr(args,
"run_dir",
None)
and os.path.isdir(args.run_dir):
25775 elif getattr(args,
"study_dir",
None)
and os.path.isdir(args.study_dir):
25778 run_dir=getattr(args,
"run_dir",
None),
25779 study_dir=getattr(args,
"study_dir",
None),
25782 require_storage_payload_local(target_context[
"root_dir"],
"submission")
25783 except StorageError
as exc:
25784 print(f
"[FATAL] {exc}", file=sys.stderr)
25787 target_context[
"root_dir"]
25789 for warning
in preflight_warnings:
25790 print(f
"[WARNING] {warning.strip()}", file=sys.stderr)
25791 if preflight_errors:
25793 "[FATAL] Submission preflight failed: the staged run has an unsafe run-directory "
25797 for violation
in preflight_errors:
25798 print(violation, file=sys.stderr)
25800 " Re-stage the run so PICurv regenerates its canonical path flags.",
25804 if target_context[
"target_kind"] ==
"study":
25805 cold_cases = cold_study_members(target_context[
"root_dir"])
25808 "[FATAL] Submission requires cold-storage study member(s): " +
", ".join(cold_cases),
25812 stage_order = [
"solve",
"post-process"]
25813 requested_stage = args.stage
25817 pending_stages = [stage
for stage
in recorded_stages
25819 selected_stages = ([requested_stage]
if requested_stage !=
"all"
25820 else pending_stages
or recorded_stages
or stage_order)
25822 print(f
"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
25823 print(f
"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
25824 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
25826 if target_context.get(
"launch_mode") ==
"local":
25832 solve_existing_job_id = str(solve_existing_meta.get(
"job_id",
"")).strip()
25834 for stage_name
in selected_stages:
25836 script_path = target_context[
"script_map"][stage_name]
25838 if not existing_meta:
25840 ERROR_CODE_CFG_MISSING_KEY,
25841 key=f
"scheduler.{stage_name}.metadata",
25842 file_path=target_context[
"submission_path"],
25843 message=f
"Submission metadata does not record stage '{stage_name}'.",
25844 hint=missing_stage_hint,
25848 if not os.path.isfile(script_path):
25850 ERROR_CODE_CFG_FILE_NOT_FOUND,
25851 key=f
"scheduler.{stage_name}.script",
25852 file_path=script_path,
25853 message=f
"Required {stage_name} sbatch artifact is missing.",
25854 hint=missing_stage_hint,
25858 if existing_meta.get(
"submitted")
and not args.force:
25860 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25861 key=f
"scheduler.{stage_name}.submitted",
25862 file_path=target_context[
"submission_path"],
25863 message=f
"Stage '{stage_name}' is already recorded as submitted.",
25864 hint=
"Use --force to resubmit this stage intentionally.",
25869 if stage_name ==
"post-process":
25870 if "solve" in selected_stages:
25871 dependency =
"__NEW_SOLVE_JOB_ID__"
25872 elif solve_existing_meta.get(
"submitted")
and solve_existing_job_id:
25873 dependency = solve_existing_job_id
25875 if end ==
"completed":
25879 print(f
"[INFO] Solve job {solve_existing_job_id} has completed; the post job is "
25880 "submitted without a dependency.")
25882 elif end ==
"failed" and not args.force:
25884 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25885 key=
"scheduler.post-process.dependency",
25886 file_path=target_context[
"submission_path"],
25887 message=f
"Solve job {solve_existing_job_id} ended {state}, so a post job waiting on its success would never start.",
25888 hint=
"Resubmit the solve, or use --force to post-process the checkpoints it committed.",
25891 elif end !=
"active" and end !=
"unchecked":
25893 print(f
"[WARNING] Solve job {solve_existing_job_id} is no longer queued "
25894 f
"({state or 'end state unavailable from sacct'}); the post job is submitted "
25895 "without a dependency and processes what was committed.", file=sys.stderr)
25897 elif solve_existing_meta
and not args.force:
25901 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25902 key=
"scheduler.post-process.dependency",
25903 file_path=target_context[
"submission_path"],
25904 message=
"This run's staged solve has not been submitted, so its post-process job would have nothing to read.",
25905 hint=
"Submit --stage solve or --stage all first, or use --force to submit the post job without a dependency.",
25909 stage_plans.append(
25911 "stage": stage_name,
25912 "script": script_path,
25913 "dependency": dependency,
25914 "existing_meta": existing_meta,
25920 if target_context[
"target_kind"] ==
"study" and "post-process" in selected_stages:
25922 metrics_script = target_context[
"script_map"][
"metrics"]
25923 if metrics_meta
and os.path.isfile(metrics_script)
and (
not metrics_meta.get(
"submitted")
or args.force):
25924 stage_plans.append({
25925 "stage":
"metrics",
25926 "script": metrics_script,
25927 "dependency":
"__NEW_POST_JOB_ID__",
25928 "dependency_type":
"afterany",
25929 "existing_meta": metrics_meta,
25933 for plan
in stage_plans:
25935 dependency = plan[
"dependency"]
25936 dependency_type = plan.get(
"dependency_type",
"afterok")
25937 if dependency ==
"__NEW_SOLVE_JOB_ID__":
25938 cmd.append(
"--dependency=afterok:<new solve job id>")
25939 elif dependency ==
"__NEW_POST_JOB_ID__":
25940 cmd.append(f
"--dependency={dependency_type}:<new post job id>")
25942 cmd.append(f
"--dependency={dependency_type}:{dependency}")
25943 cmd.append(plan[
"script"])
25944 print(f
"[DRY-RUN] Would run: {' '.join(cmd)}")
25945 print(
"[INFO] Dry-run only. No jobs were submitted.")
25949 for plan
in stage_plans:
25950 dependency = plan[
"dependency"]
25951 dependency_type = plan.get(
"dependency_type",
"afterok")
25952 if dependency ==
"__NEW_SOLVE_JOB_ID__":
25953 dependency = new_job_ids.get(
"solve")
25954 elif dependency ==
"__NEW_POST_JOB_ID__":
25955 dependency = new_job_ids.get(
"post-process")
25957 submit_info =
submit_sbatch(plan[
"script"], dependency=dependency, dependency_type=dependency_type)
25958 stage_meta = copy.deepcopy(plan[
"existing_meta"])
25959 stage_meta.update(submit_info)
25960 stage_meta[
"script"] = plan[
"script"]
25961 stage_meta[
"submitted"] =
True
25963 stage_meta[
"dependency"] = f
"{dependency_type}:{dependency}"
25965 stage_meta.pop(
"dependency",
None)
25968 print(f
"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
25969 new_job_ids[plan[
"stage"]] = submit_info[
"job_id"]
25976 @brief Execute previously staged local run commands from scheduler/submission.json.
25977 @param[in] args Command-line style argument list supplied to the function.
25978 @param[in] target_context Resolved submission target context.
25979 @param[in] selected_stages Ordered stage names selected by the user.
25981 if target_context[
"target_kind"] !=
"run":
25983 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25984 key=
"scheduler.launch_mode",
25985 file_path=target_context[
"submission_path"],
25986 message=
"Local staged execution is supported for run directories only.",
25987 hint=
"Use --run-dir for local staged execution.",
25995 solve_pending = bool(solve_existing_meta)
and not bool(
25996 solve_existing_meta.get(
"submitted")
or solve_existing_meta.get(
"executed"))
25998 for stage_name
in selected_stages:
26000 command = existing_meta.get(
"command")
26001 if not isinstance(command, list)
or not command:
26003 hint =
"Re-stage the run with picurv run --no-submit before calling picurv submit."
26007 ERROR_CODE_CFG_MISSING_KEY,
26008 key=f
"scheduler.{stage_name}.command",
26009 file_path=target_context[
"submission_path"],
26010 message=f
"Required local command metadata for stage '{stage_name}' is missing.",
26015 if existing_meta.get(
"submitted")
and not args.force:
26017 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26018 key=f
"scheduler.{stage_name}.submitted",
26019 file_path=target_context[
"submission_path"],
26020 message=f
"Stage '{stage_name}' is already recorded as submitted.",
26021 hint=
"Use --force to execute this stage again intentionally.",
26025 if stage_name ==
"post-process" and "solve" not in selected_stages
and not args.force
and solve_pending:
26027 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26028 key=
"scheduler.post-process.dependency",
26029 file_path=target_context[
"submission_path"],
26030 message=
"This run's staged solve has not run yet, so its post-process stage has nothing to read.",
26031 hint=
"Submit --stage solve or --stage all first, or use --force to run the post stage anyway.",
26035 log_file = existing_meta.get(
"log_file")
26036 if not isinstance(log_file, str)
or not log_file.strip():
26037 log_file = os.path.join(
"scheduler", f
"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
26039 stage_plans.append(
26041 "stage": stage_name,
26042 "command": [str(token)
for token
in command],
26043 "log_file": log_file,
26044 "existing_meta": existing_meta,
26049 for plan
in stage_plans:
26050 print(f
"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
26051 print(f
"[DRY-RUN] Log file : {plan['log_file']}")
26052 print(
"[INFO] Dry-run only. No local commands were executed.")
26056 monitor_path = os.path.join(target_context[
"root_dir"],
"config",
"monitor.yml")
26057 if os.path.isfile(monitor_path):
26060 for plan
in stage_plans:
26061 if plan[
"stage"] ==
"solve":
26063 with runtime_stage_lock(target_context[
"root_dir"],
"solver"):
26064 execute_command(plan[
"command"], target_context[
"root_dir"], plan[
"log_file"], monitor_cfg)
26065 except StorageError
as exc:
26066 print(f
"[FATAL] {exc}", file=sys.stderr)
26069 execute_command(plan[
"command"], target_context[
"root_dir"], plan[
"log_file"], monitor_cfg)
26070 stage_meta = copy.deepcopy(plan[
"existing_meta"])
26071 stage_meta[
"command"] = plan[
"command"]
26073 stage_meta[
"log_file"] = plan[
"log_file"]
26074 stage_meta[
"submitted"] =
True
26075 stage_meta[
"executed"] =
True
26076 stage_meta[
"completed_at"] = datetime.now().isoformat()
26078 print(f
"[SUCCESS] Executed local {plan['stage']} stage.")
26085 @brief Cancel Slurm-submitted jobs for an existing run directory.
26086 @param[in] args Command-line style argument list supplied to the function.
26088 run_dir = os.path.abspath(args.run_dir)
26089 if not os.path.isdir(run_dir):
26091 ERROR_CODE_CFG_FILE_NOT_FOUND,
26094 message=
"Run directory not found.",
26098 submission_path = os.path.join(run_dir,
"scheduler",
"submission.json")
26100 if not isinstance(submission_meta, dict):
26102 ERROR_CODE_CFG_FILE_NOT_FOUND,
26103 key=
"scheduler.submission",
26104 file_path=submission_path,
26105 message=
"Run directory does not contain scheduler submission metadata.",
26106 hint=
"Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
26110 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
26111 if launch_mode !=
"slurm":
26113 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26114 key=
"scheduler.launch_mode",
26115 file_path=submission_path,
26116 message=f
"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
26117 hint=
"picurv cancel currently supports Slurm-submitted runs only.",
26121 stage_order = [
"solve",
"post-process"]
26122 requested_stage = args.stage
26123 selected_stages = stage_order
if requested_stage ==
"all" else [requested_stage]
26124 recorded_stages = submission_meta.get(
"stages", {})
26125 if not isinstance(recorded_stages, dict):
26126 recorded_stages = {}
26130 for stage_name
in selected_stages:
26131 stage_meta = recorded_stages.get(stage_name)
26132 if not isinstance(stage_meta, dict):
26133 skipped.append((stage_name,
"no stage metadata recorded"))
26136 job_id = str(stage_meta.get(
"job_id",
"")).strip()
26137 if not stage_meta.get(
"submitted"):
26138 skipped.append((stage_name,
"job was generated but not submitted"))
26141 skipped.append((stage_name,
"submitted stage is missing a recorded job id"))
26144 job_to_stages.setdefault(job_id, []).append(stage_name)
26146 if not job_to_stages:
26147 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
26148 print(f
"[INFO] Submission metadata : {os.path.relpath(submission_path)}")
26149 for stage_name, reason
in skipped:
26150 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
26151 print(
"[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
26154 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
26155 print(f
"[INFO] Submission metadata : {os.path.relpath(submission_path)}")
26156 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
26159 for stage_name, reason
in skipped:
26160 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
26162 graceful = bool(getattr(args,
"graceful",
False))
26164 for job_id, stage_names
in job_to_stages.items():
26165 joined_stage_names =
", ".join(stage_names)
26166 use_graceful_signal = graceful
and "solve" in stage_names
26167 scancel_cmd = [
"scancel"]
26168 if use_graceful_signal:
26173 scancel_cmd.extend([
"--signal=USR1",
"--full"])
26174 scancel_cmd.append(job_id)
26177 print(f
"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
26183 if end_state
and status !=
"active":
26184 print(f
"[INFO] Slurm job {job_id} for stage(s) {joined_stage_names} already ended "
26185 f
"({end_state}); nothing to cancel.")
26188 result = subprocess.run(scancel_cmd, text=
True, capture_output=
True, check=
False)
26189 stderr_text = (result.stderr
or "").strip()
26190 stdout_text = (result.stdout
or "").strip()
26191 if result.returncode == 0:
26192 if use_graceful_signal:
26194 f
"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
26195 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
26198 print(f
"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
26201 detail = stderr_text
or stdout_text
or "unknown scancel failure"
26202 failures.append((job_id, joined_stage_names, detail, result.returncode))
26204 f
"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
26209 print(
"[INFO] Dry-run only. No jobs were canceled.")
26218 @brief Infer the owned workspace role of one copied YAML file.
26219 @param[in] path YAML file copied from an example template.
26220 @return One of case, solver, monitor, post, cluster, study, or None.
26224 except (OSError, ValueError):
26226 keys = set(payload)
26227 if {
"grid",
"properties",
"run_control"} <= keys:
26229 if "base_configs" in keys
and (
"study_type" in keys
or "parameters" in keys
or "parameter_sets" in keys):
26231 if "scheduler" in keys
and "resources" in keys:
26235 post_only = _POST_SCHEMA[()] - _MONITOR_SCHEMA[()] - {
"run_control"}
26236 if keys & post_only
or (
"run_control" in keys
and not keys & _CASE_SCHEMA[()] - {
"run_control",
"title"}):
26238 if "io" in keys
and (
"logging" in keys
or "profiling" in keys
or "diagnostics" in keys):
26240 if "momentum_solver" in keys
or "poisson_solver" in keys
or "operation_mode" in keys:
26247 @brief Rewrite copied template path scalars to workspace-root-relative homes.
26248 @param[in] value YAML subtree to rewrite.
26249 @param[in] replacements Old relative/basename paths mapped to new workspace paths.
26250 @return Rewritten YAML subtree.
26252 if isinstance(value, dict):
26254 if isinstance(value, list):
26256 if not isinstance(value, str):
26258 normalized = value.replace(
"\\",
"/").lstrip(
"./")
26259 return replacements.get(normalized, replacements.get(os.path.basename(normalized), value))
26264 @brief Select the canonical role file from a template that may carry variants.
26265 @param[in] candidates Candidate absolute YAML paths.
26266 @param[in] role Config role being selected.
26267 @param[in] template_name Source example directory name.
26268 @return Selected absolute path or None.
26273 "case": (
"case.yml", f
"{template_name}.yml",
"master_case.yml"),
26274 "solver": (
"solver.yml",
"Imp-MG-Standard.yml",
"master_solver.yml"),
26275 "monitor": (
"monitor.yml",
"Standard_Output.yml",
"master_monitor.yml"),
26276 "post": (
"post.yml",
"standard_analysis.yml",
"master_postprocessor.yml"),
26277 "cluster": (
"cluster.yml",
"slurm_cluster.yml",
"master_cluster.yml"),
26279 by_name = {os.path.basename(path): path
for path
in candidates}
26280 for name
in preferred:
26281 if name
in by_name:
26282 return by_name[name]
26283 return sorted(candidates)[0]
26287 source_template_root: str =
None) -> dict:
26289 @brief Convert a copied example into the canonical editable workspace layout.
26290 @param[in] workspace_root Newly copied workspace root.
26291 @param[in] template_name Example template identity.
26292 @param[in] source_template_root Optional original template root used to vendor references.
26293 @return Mapping of canonical config roles and relocated imported inputs.
26295 workspace_root = os.path.abspath(workspace_root)
26301 for pattern
in (
"*.yml",
"*.yaml")
26302 for path
in Path(workspace_root).rglob(pattern)
26303 if path.name
not in {RUNTIME_EXECUTION_EXAMPLE_FILENAME, RUNTIME_EXECUTION_CONFIG_FILENAME}
26304 and not {
"runs",
"studies"} & set(path.relative_to(workspace_root).parts)
26306 vendored_replacements = {}
26307 if source_template_root:
26308 source_template_root = os.path.abspath(source_template_root)
26310 def vendor_references(value, source_yaml: str, dotted: str =
""):
26312 @brief Vendor referenced generator inputs into canonical workspace homes.
26313 @param[in] value YAML subtree to inspect.
26314 @param[in] source_yaml Original template YAML path.
26315 @param[in] dotted Current dotted key path.
26318 if isinstance(value, dict):
26319 for key, child
in value.items():
26320 vendor_references(child, source_yaml, f
"{dotted}.{key}" if dotted
else str(key))
26322 if isinstance(value, list):
26323 for index, child
in enumerate(value):
26324 vendor_references(child, source_yaml, f
"{dotted}[{index}]")
26326 key = dotted.rsplit(
".", 1)[-1]
26327 if key
not in _VENDORABLE_CONFIG_REFERENCE_KEYS
or not isinstance(value, str)
or not value.strip():
26329 origin = os.path.abspath(os.path.join(os.path.dirname(source_yaml), value))
26330 if not os.path.isfile(origin):
26332 if key ==
"script":
26333 home =
"config/generators"
26334 elif "grid" in dotted:
26335 home =
"config/grids"
26336 elif "initial_condition" in dotted:
26337 home =
"config/initial_conditions"
26338 elif "inlet" in dotted
or "boundary_conditions" in dotted:
26339 home =
"config/inlet_profiles"
26342 destination = os.path.join(workspace_root, *home.split(
"/"), os.path.basename(origin))
26343 os.makedirs(os.path.dirname(destination), exist_ok=
True)
26344 if not os.path.exists(destination):
26345 shutil.copy2(origin, destination)
26346 relative = os.path.relpath(destination, workspace_root).replace(os.sep,
"/")
26347 vendored_replacements[value.replace(
"\\",
"/").lstrip(
"./")] = relative
26348 vendored_replacements[os.path.basename(value)] = relative
26350 for copied_yaml
in sorted(set(yaml_files)):
26351 relative = os.path.relpath(copied_yaml, workspace_root)
26352 source_yaml = os.path.join(source_template_root, relative)
26353 if not os.path.isfile(source_yaml):
26357 except (OSError, ValueError):
26360 for path
in sorted(set(yaml_files)):
26363 roles.setdefault(role, []).append(path)
26367 for role, paths
in roles.items()
if role !=
"study"
26371 for role, paths
in roles.items():
26372 for source
in paths:
26373 if role ==
"study":
26374 relative = os.path.join(
"config",
"studies", os.path.basename(source))
26375 elif source == selected.get(role):
26376 relative = os.path.join(
"config", f
"{role}.yml")
26378 relative = os.path.join(
"config", os.path.basename(source))
26379 destination = os.path.join(workspace_root, relative)
26380 if os.path.abspath(source) == os.path.abspath(destination):
26381 destinations[source] = relative.replace(os.sep,
"/")
26382 occupied.add(os.path.abspath(destination))
26384 if os.path.abspath(destination)
in occupied
or os.path.exists(destination):
26385 original_relative = os.path.relpath(source, workspace_root)
26386 relative = os.path.join(
"config",
"variants", original_relative)
26387 destination = os.path.join(workspace_root, relative)
26389 while os.path.abspath(destination)
in occupied
or os.path.exists(destination):
26390 stem, suffix = os.path.splitext(relative)
26391 destination = os.path.join(workspace_root, f
"{stem}-{counter}{suffix}")
26393 os.makedirs(os.path.dirname(destination), exist_ok=
True)
26394 shutil.move(source, destination)
26395 destinations[source] = relative.replace(os.sep,
"/")
26396 occupied.add(os.path.abspath(destination))
26398 input_extensions = {
26399 ".picgrid":
"inputs/grids",
26400 ".vts":
"inputs/grids",
26401 ".picslice":
"inputs/inlet_profiles",
26402 ".dat":
"inputs/initial_conditions",
26405 for path
in sorted(Path(workspace_root).rglob(
"*")):
26406 if not path.is_file()
or path.suffix.lower()
not in input_extensions:
26408 if any(part
in _WORKSPACE_MANAGED_PATHS
for part
in path.relative_to(workspace_root).parts):
26410 destination_dir = os.path.join(workspace_root, *input_extensions[path.suffix.lower()].split(
"/"))
26411 destination = os.path.join(destination_dir, path.name)
26412 if os.path.exists(destination):
26414 old_relative = path.relative_to(workspace_root).as_posix()
26415 shutil.move(str(path), destination)
26416 new_relative = os.path.relpath(destination, workspace_root).replace(os.sep,
"/")
26417 input_moves[old_relative] = new_relative
26418 input_moves[path.name] = new_relative
26420 config_replacements = {}
26421 basename_counts = {}
26422 for source
in destinations:
26423 basename = os.path.basename(source)
26424 basename_counts[basename] = basename_counts.get(basename, 0) + 1
26425 for source, relative
in destinations.items():
26426 old_relative = os.path.relpath(source, workspace_root).replace(os.sep,
"/")
26427 config_replacements[old_relative] = relative
26428 if basename_counts[os.path.basename(source)] == 1:
26429 config_replacements[os.path.basename(source)] = relative
26430 replacements = {**config_replacements, **input_moves, **vendored_replacements}
26431 for path
in sorted(Path(workspace_root,
"config").rglob(
"*.yml")):
26433 local_replacements = dict(replacements)
26434 original_source = next(
26436 source
for source, relative
in destinations.items()
26437 if os.path.abspath(os.path.join(workspace_root, relative)) == os.path.abspath(path)
26441 if original_source:
26442 original_parent = os.path.dirname(original_source)
26443 for candidate_source, candidate_relative
in destinations.items():
26444 relative_reference = os.path.relpath(candidate_source, original_parent).replace(os.sep,
"/")
26445 local_replacements[relative_reference] = candidate_relative
26446 if os.path.dirname(candidate_source) == original_parent:
26447 local_replacements[os.path.basename(candidate_source)] = candidate_relative
26450 if role ==
"monitor" and isinstance(rewritten.get(
"io"), dict):
26451 rewritten[
"io"].pop(
"directories",
None)
26452 if role ==
"case" and path == Path(workspace_root,
"config",
"case.yml"):
26453 rewritten.setdefault(
"title", template_name)
26455 if isinstance(rewritten.get(
"source_data"), dict):
26456 rewritten[
"source_data"].pop(
"directory",
None)
26457 if isinstance(rewritten.get(
"io"), dict):
26458 rewritten[
"io"].pop(
"output_directory",
None)
26459 if role ==
"study" and isinstance(rewritten.get(
"base_configs"), dict):
26460 for role
in (
"case",
"solver",
"monitor",
"post"):
26461 canonical = os.path.join(
"config", f
"{role}.yml").replace(os.sep,
"/")
26462 if os.path.isfile(os.path.join(workspace_root, canonical)):
26463 rewritten[
"base_configs"][role] = canonical
26468 "canonical_roles": {
26469 role: os.path.join(workspace_root,
"config", f
"{role}.yml")
26470 for role
in (
"case",
"solver",
"monitor",
"post",
"cluster")
26471 if os.path.isfile(os.path.join(workspace_root,
"config", f
"{role}.yml"))
26473 "input_moves": input_moves,
26477WORKSPACE_INPUT_DIRECTORIES = {
26478 "grid":
"inputs/grids",
26479 "initial-condition":
"inputs/initial_conditions",
26480 "inlet-profile":
"inputs/inlet_profiles",
26481 "reference-field":
"inputs/reference_fields",
26486 name: str =
None, mode: str =
"copy") -> dict:
26488 @brief Explicitly import or register one workspace input.
26489 @param[in] workspace_root Initialized workspace root.
26490 @param[in] kind Semantic input kind.
26491 @param[in] source Existing source file path.
26492 @param[in] name Optional destination basename.
26493 @param[in] mode Copy, reflink, hardlink, or external-reference mode.
26494 @return Catalog entry describing the durable input identity.
26496 workspace_root = os.path.abspath(workspace_root)
26499 if kind
not in WORKSPACE_INPUT_DIRECTORIES:
26500 raise ValueError(f
"Unsupported input kind: {kind}")
26501 source = os.path.abspath(os.path.expanduser(source))
26502 if not os.path.isfile(source):
26503 raise ValueError(f
"Input source is not a file: {source}")
26504 basename = name
or os.path.basename(source)
26505 if basename != os.path.basename(basename)
or basename
in _PLAIN_FILENAME_SENTINELS:
26506 raise ValueError(
"--name must be a plain filename without directory traversal.")
26507 target_dir = os.path.join(workspace_root, *WORKSPACE_INPUT_DIRECTORIES[kind].split(
"/"))
26508 os.makedirs(target_dir, exist_ok=
True)
26509 if mode ==
"reference":
26510 destination = os.path.join(target_dir, basename +
".reference.yml")
26512 "schema_version": 1,
26513 "picurv_external_reference": source,
26515 "bytes_at_registration": os.path.getsize(source),
26518 destination = os.path.join(target_dir, basename)
26519 if os.path.exists(destination):
26520 raise ValueError(f
"Workspace input already exists: {destination}")
26521 temporary = f
"{destination}.tmp.{os.getpid()}"
26524 shutil.copy2(source, temporary)
26525 elif mode ==
"hardlink":
26526 os.link(source, temporary)
26527 elif mode ==
"reflink":
26528 cp = shutil.which(
"cp")
26530 raise ValueError(
"reflink mode requires the 'cp' command.")
26531 result = subprocess.run(
26532 [cp,
"--reflink=always",
"--preserve=mode,timestamps", source, temporary],
26533 text=
True, capture_output=
True, check=
False,
26535 if result.returncode != 0:
26536 raise ValueError((result.stderr
or "reflink copy failed").strip())
26538 raise ValueError(f
"Unsupported import mode: {mode}")
26539 os.replace(temporary, destination)
26541 if os.path.lexists(temporary):
26542 os.remove(temporary)
26543 relative = os.path.relpath(destination, workspace_root).replace(os.sep,
"/")
26545 catalog_path = os.path.join(workspace_root,
"inputs",
"catalog.yml")
26546 catalog =
read_yaml_file(catalog_path)
if os.path.isfile(catalog_path)
else {
26547 "schema_version": 1,
"inputs": {}
26549 catalog.setdefault(
"inputs", {})[entry_id] = {
26554 "bytes": os.path.getsize(source),
26555 "source": source
if mode ==
"reference" else None,
26556 "registered_at": datetime.now().astimezone().isoformat(),
26559 return {
"id": entry_id, **catalog[
"inputs"][entry_id]}
26564 @brief Handle explicit workspace input management.
26565 @param[in] args Parsed inputs command arguments.
26568 workspace_root = os.path.abspath(args.workspace)
if args.workspace
else find_workspace_root(os.getcwd())
26569 if not workspace_root:
26570 raise ValueError(
"No initialized workspace found; run picurv init first or pass --workspace.")
26571 if args.inputs_action !=
"import":
26572 raise ValueError(f
"Unsupported inputs action: {args.inputs_action}")
26574 workspace_root, args.kind, args.source, name=args.name, mode=args.mode
26576 print(f
"[SUCCESS] Registered input {entry['id']}: {entry['path']}")
26577 if entry[
"mode"] ==
"reference":
26578 print(
"[WARNING] This is an external reference. Storage will record it but will not copy or prune its target.")
26583 @brief Report, and for the `status` action validate, the shared build identity.
26585 @details Bare `picurv version` reports and always succeeds; it is an informational
26586 surface that scripts and documentation already depend on. `picurv version
26587 status` additionally exits non-zero when the conductor, the executables,
26588 and the workspace requirement do not agree, so a job script can refuse to
26589 launch a run whose provenance would be incoherent.
26590 @param[in] args Parsed version command arguments.
26595 payload[
"source_root"] = PACKAGE_PROJECT_ROOT
26596 payload[
"workspace"] = workspace_root
26597 payload[
"workspace_requirement"] =
None
26600 payload[
"workspace_requirement"] = software.get(
"picurv")
if isinstance(software, dict)
else None
26602 for identity
in payload[
"binaries"].values():
26604 identity[
"petsc_library"] = library
26606 validating = getattr(args,
"version_action",
None) ==
"status"
26608 payload[
"coherent"] =
not problems
26609 payload[
"problems"] = problems
26610 if getattr(args,
"output_format",
"text") ==
"json":
26611 print(json.dumps(payload, indent=2, sort_keys=
True))
26612 if validating
and problems:
26615 print(f
"PICurv release : {payload['release_version']}")
26616 print(f
"Build identity : {payload['build_id']}")
26617 print(f
"Git commit : {payload.get('git_commit') or 'unavailable'}")
26618 print(f
"Dirty tree : {payload.get('dirty') if payload.get('dirty') is not None else 'unknown'}")
26620 print(f
"Workspace : {workspace_root}")
26621 print(f
"Requirement : {payload['workspace_requirement'] or 'latest active version'}")
26622 print(
"\nNative executables")
26623 for name, identity
in sorted(payload[
"binaries"].items()):
26624 if not identity.get(
"available"):
26625 print(f
" {name:<15}: unavailable ({identity.get('reason', 'unknown')})")
26627 agreement =
"matches source" if identity[
"matches_source"]
else "STALE - rebuild"
26628 print(f
" {name:<15}: {identity['build_id']} ({agreement})")
26631 print(
"\nBuild identity is coherent.")
26633 print(
"\nBuild identity is NOT coherent:", file=sys.stderr)
26634 for problem
in problems:
26635 print(f
" - {problem}", file=sys.stderr)
26642 @brief Print the PETSc an executable was built against and the library it would load.
26643 @param[in] identity One executable's entry from `runtime_build_identities()`, with
26644 `petsc_library` and `petsc_library_mismatch` filled in.
26647 petsc = identity.get(
"petsc")
26649 print(f
" {'':<15} PETSc: not recorded (built before PETSc stamping)")
26651 location =
", ".join(part
for part
in (petsc.get(
"arch"), petsc.get(
"dir"))
if part)
26652 bits = 8 * PETSC_INDEX_BYTES[petsc.get(
"indices",
"int32")]
26653 print(f
" {'':<15} PETSc: {petsc['version']} {petsc['mode']}, {bits}-bit indices ({location})")
26654 library = identity.get(
"petsc_library")
26656 flag = (
" <- NOT the PETSc it was built against; load that PETSc's module, "
26657 "or rebuild under this one")
if identity[
"petsc_library_mismatch"]
else ""
26658 print(f
" {'':<15} loads: {library}{flag}")
26663 @brief Refuse version-changing Git operations in a dirty source checkout.
26664 @param[in] action User-facing action name.
26667 result = subprocess.run(
26668 [
"git",
"status",
"--porcelain"], cwd=PACKAGE_PROJECT_ROOT,
26669 text=
True, capture_output=
True, check=
False,
26671 if result.returncode != 0:
26672 raise ValueError(f
"{action}: cannot inspect the PICurv Git checkout.")
26673 if result.stdout.strip():
26675 f
"{action}: the PICurv source checkout has uncommitted changes. "
26676 "Commit or stash them before changing versions."
26682 @brief Run a checked Git command against the active PICurv source checkout.
26683 @param[in] arguments Git arguments excluding the executable.
26684 @return Completed successful Git process.
26686 result = subprocess.run(
26687 [
"git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
26688 text=
True, capture_output=
True, check=
False,
26690 if result.returncode != 0:
26691 raise ValueError((result.stderr
or result.stdout
or "Git command failed.").strip())
26697 @brief Fetch source history without silently changing the active code.
26698 @param[in] args Parsed source command arguments.
26701 if args.source_action !=
"update":
26702 raise ValueError(f
"Unsupported source action: {args.source_action}")
26704 print(f
"[SUCCESS] Fetched branches and tags from {args.remote}; active checkout was not changed.")
26709 @brief Resolve an exact version requested by an initialized workspace.
26710 @param[in] workspace_root Initialized workspace root.
26711 @return Exact version or tag text.
26714 requirement = software.get(
"picurv")
if isinstance(software, dict)
else None
26715 if not requirement:
26717 f
"{workspace_root}/{WORKSPACE_CONFIG_FILENAME} has no software.picurv pin; "
26718 "name a version explicitly."
26720 if any(token
in str(requirement)
for token
in "<>=!~,*"):
26722 "Automatic activation needs an exact release/tag, not a version range. "
26723 f
"Pass the desired version explicitly (workspace requires {requirement!r})."
26725 return str(requirement)
26730 @brief List or activate a release using the existing source/build owners.
26731 @param[in] args Parsed versions command arguments.
26734 action = args.versions_action
26735 if action ==
"list":
26737 print(f
"Active: {picurv_build()['build_id']}")
26738 tags = [line
for line
in result.stdout.splitlines()
if line.strip()]
26740 print(
"Installed/available tags:")
26744 print(
"No version tags are present in this checkout.")
26746 version = getattr(args,
"version",
None)
26747 make_args =
list(getattr(args,
"make_args",
None)
or [])
26748 if version
and (_MAKE_ASSIGNMENT_PATTERN.match(str(version))
or str(version).startswith(
"-")):
26753 make_args.insert(0, str(version))
26757 f
"versions {action} builds the default target; pass only make variables and "
26758 f
"options (for example SYSTEM=cluster), not a target: {' '.join(make_args)}"
26760 if action ==
"activate" and not version:
26761 workspace_root = os.path.abspath(args.workspace)
if args.workspace
else find_workspace_root(os.getcwd())
26762 if not workspace_root:
26763 raise ValueError(
"No workspace found and no version was named.")
26766 raise ValueError(f
"versions {action}: name the tag or commit to install.")
26767 if action
not in _VERSION_BUILD_ACTIONS:
26768 raise ValueError(f
"Unsupported versions action: {action}")
26774 f
"[WARNING] {PACKAGE_PROJECT_ROOT} is a single shared installation. Building "
26775 f
"{version!r} here re-points every workspace that resolves executables from it, "
26776 "including any job already running against them.",
26780 " Runs staged with --pin-executables keep their own copies and are unaffected; "
26781 "unpinned queued jobs stop at their job-start identity check.",
26796 f
"Built {ref!r}, but the executables do not carry its identity "
26797 f
"({source_identity['build_id']}):\n - " +
"\n - ".join(problems)
26799 print(f
"[SUCCESS] Activated and built PICurv {ref} ({source_identity['build_id']}).")
26804 @brief Resolve a user-named version to a Git ref that exists in the source checkout.
26805 @details Release tags are spelled `v<release>`, while `VERSION` and workspace
26806 `software.picurv` pins carry the bare release. The name is tried as given
26807 first, so a commit, branch, or already-prefixed tag is never rewritten.
26808 @param[in] version Tag, bare release, or commit named by the user or workspace pin.
26809 @return Ref accepted by `git checkout --detach`.
26810 @throws ValueError when neither spelling resolves to a commit.
26812 candidates = [version]
26813 if not version.startswith(
"v"):
26814 candidates.append(f
"v{version}")
26815 for candidate
in candidates:
26816 result = subprocess.run(
26817 [
"git",
"rev-parse",
"--verify",
"--quiet", f
"{candidate}^{{commit}}"],
26818 cwd=PACKAGE_PROJECT_ROOT, text=
True, capture_output=
True, check=
False,
26820 if result.returncode == 0:
26823 f
"No tag or commit named {' or '.join(repr(c) for c in candidates)} exists in "
26824 f
"{PACKAGE_PROJECT_ROOT}; 'picurv versions list' shows the available tags."
26830 @brief Implements the 'init' command.
26831 @details Creates a new case study directory by copying a template.
26832 Runtime binaries are resolved from the project bin/ directory
26833 via PATH; pass --pin-binaries to pin specific versions locally.
26834 @param[in] args The command-line arguments parsed by argparse.
26840 except ValueError
as exc:
26841 print(f
"[FATAL] {exc}", file=sys.stderr)
26845 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name
if args.dest_name
else args.template_name))
26847 if os.path.exists(dest_path):
26848 print(f
"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
26851 print(f
"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
26856 print(f
"[SUCCESS] Copied template files to: {dest_path}")
26857 print(f
"[INFO] Wrote workspace identity: {os.path.relpath(workspace_layout['workspace_config'])}")
26858 if workspace_layout[
"canonical_roles"]:
26859 print(
"[INFO] Canonical editable configurations:")
26860 for role, path
in sorted(workspace_layout[
"canonical_roles"].items()):
26861 print(f
" - {role}: {os.path.relpath(path)}")
26862 except Exception
as exc:
26863 print(f
"[FATAL] Failed to create canonical workspace layout: {exc}", file=sys.stderr)
26864 shutil.rmtree(dest_path, ignore_errors=
True)
26869 print(f
"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
26870 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
26871 print(
" Seeded from repo-local '.picurv-execution.yml'.")
26872 print(
" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
26873 except Exception
as e:
26874 print(f
"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
26879 source_project_root,
26880 template_name=args.template_name,
26881 template_managed_files=template_managed_files,
26883 print(f
"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
26884 except Exception
as e:
26885 print(f
"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
26887 cluster_profile_candidates = sorted(
26889 os.path.basename(path)
26890 for pattern
in (
"*cluster*.yml",
"*cluster*.yaml")
26891 for search_root
in (dest_path, os.path.join(dest_path,
"config"))
26892 for path
in glob.glob(os.path.join(search_root, pattern))
26895 if cluster_profile_candidates:
26896 print(
"[INFO] Cluster profile sample(s) copied with this case:")
26897 for profile_name
in cluster_profile_candidates:
26898 print(f
" - {profile_name}")
26899 print(
" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
26901 if getattr(args,
"pin_binaries",
False):
26902 print(
"[INFO] Pinning runtime binaries into case directory...")
26905 for dest_file_path
in copied_binaries:
26906 print(f
" - Pinned '{os.path.basename(dest_file_path)}'")
26907 print(
"[SUCCESS] Case directory is ready with pinned binaries.")
26908 print(
" These local copies will be used instead of bin/ originals.")
26909 except ValueError
as exc:
26910 print(f
"[WARNING] {exc}", file=sys.stderr)
26911 print(
" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
26913 print(
"[SUCCESS] Case directory is ready.")
26914 print(
" Runtime binaries (simulator, postprocessor) are resolved from the active PICurv installation.")
26915 print(
" Pin software.picurv in .picurv-workspace.yml only when this workspace needs a release constraint.")
26916 print(
" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
26921 @brief Refresh template-managed config/docs files in a case directory.
26922 @param[in] args Command-line style argument list supplied to the function.
26926 case_dir_hint=getattr(args,
"case_dir",
None),
26927 source_root_override=getattr(args,
"source_root",
None),
26928 template_name_override=getattr(args,
"template_name",
None),
26932 template_name = context.get(
"template_name")
26934 existing_managed = context.get(
"metadata", {}).get(
"template_managed_files")
26935 if not isinstance(existing_managed, list):
26936 existing_managed =
None
26940 overwrite=getattr(args,
"overwrite",
False),
26941 prune=getattr(args,
"prune",
False),
26942 managed_rel_paths=existing_managed,
26943 template_name=template_name,
26947 source_project_root,
26948 template_name=template_name,
26949 existing=context.get(
"metadata"),
26950 template_managed_files=summary[
"template_managed_files"],
26953 except ValueError
as exc:
26954 print(f
"[FATAL] {exc}", file=sys.stderr)
26957 print(f
"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
26958 print(f
"[INFO] Copied new files : {len(summary['copied'])}")
26959 print(f
"[INFO] Overwritten files : {len(summary['overwritten'])}")
26960 print(f
"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
26961 print(f
"[INFO] Already unchanged : {len(summary['unchanged'])}")
26962 print(f
"[INFO] Pruned stale files : {len(summary['pruned'])}")
26963 if runtime_result[
"created"]:
26964 print(f
"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
26965 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
26966 print(
"[INFO] Seed source : repo-local .picurv-execution.yml")
26967 if summary.get(
"prune_requested_without_tracking"):
26968 print(
"[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
26969 print(f
"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
26974 @brief Refresh source branches in the repository resolved from a case directory.
26975 @param[in] args Command-line style argument list supplied to the function.
26979 case_dir_hint=getattr(args,
"case_dir",
None),
26980 source_root_override=getattr(args,
"source_root",
None),
26983 except ValueError
as exc:
26984 print(f
"[FATAL] {exc}", file=sys.stderr)
26989 except ValueError
as exc:
26990 print(f
"[FATAL] {exc}", file=sys.stderr)
26993 rebase =
not getattr(args,
"no_rebase",
False)
26994 remote = getattr(args,
"remote",
None)
26995 branch = getattr(args,
"branch",
None)
26996 current_branch_only = (
26997 getattr(args,
"current_branch_only",
False)
26998 or remote
is not None
26999 or branch
is not None
27002 if not current_branch_only:
27006 command = [
"git",
"pull"]
27008 command.append(
"--rebase")
27010 command.append(remote)
27012 command.append(branch)
27014 command.extend([
"origin", branch])
27020 @brief Implements the 'build' command.
27021 @details Executes the top-level Makefile directly, passing through any
27022 additional arguments to `make`. This allows for building,
27023 cleaning, and other Makefile targets via the orchestrator
27024 without maintaining a separate build wrapper script.
27025 @param[in] args The command-line arguments parsed by argparse.
27028 print(
"\n" +
"="*27 +
" BUILD STAGE " +
"="*27)
27031 case_dir_hint=getattr(args,
"case_dir",
None),
27032 source_root_override=getattr(args,
"source_root",
None),
27035 except ValueError
as exc:
27036 print(f
"[FATAL] {exc}", file=sys.stderr)
27044 @brief Run the project Makefile with caller-supplied arguments, defaulting to `all`.
27045 @details Shared by `picurv build` and `picurv versions install/activate`, so a site
27046 setting such as `SYSTEM=cluster` reaches `make` the same way from both.
27047 @param[in] source_project_root PICurv source checkout containing the Makefile.
27048 @param[in] make_args Arguments passed through to `make`.
27049 @return None. Exits non-zero when the Makefile is missing or `make` fails.
27051 makefile_path = os.path.join(source_project_root,
"Makefile")
27053 if not os.path.isfile(makefile_path):
27054 print(f
"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
27055 print(
" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
27059 command = [
"make"] + make_args
27061 command = [
"make",
"all"] + make_args
27062 print(
"[INFO] No explicit make target supplied; defaulting to 'all'.")
27063 print(
" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
A committed checkpoint written by a PETSc of the other index width than the driven solver's.
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.
"tuple[dict, list[int]]" _parse_solution_convergence_log(str log_dir)
Read the solution-convergence record into rows by step plus observed order.
set resolve_post_stage_selection(only)
Resolve the --only selector into the set of post stages to execute.
"str | None" resolved_petsc_library(str executable_path)
The PETSc shared library the dynamic loader would give this executable when picurv launches it.
dict translate_programmatic_grid_settings(dict grid_settings)
Return programmatic-grid settings translated to the C node-count contract.
dict resolve_ic_payload_provenance(dict ic, int field_code)
Resolve which scales a file-backed initial condition was written in.
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.
load_ic_generator_module()
Load generators/ic.gen, which owns the expression language and the IC providers.
_render_monitor_summary_text(dict summary)
Render the monitor summary as a glanceable observability dashboard.
"tuple[str | None, list]" _particle_step_rows(str log_dir, str metrics_dir)
Return the per-step particle record's path and its (segment, row) pairs.
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 retired_destination_message(str label, str key)
Explain why a generator may not choose its own output destination.
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.
dict _ensemble_source_fields(str source)
Return the field table an ensemble of one source names its fields from.
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).
post_step_outputs(str run_dir, dict post_cfg, monitor_cfg=None)
Return a lookup of the output files one step of the current recipe holds.
int prepare_post_job(str run_dir, str post_recipe_path, str postprocessor, bool recompute=False)
Decide, as a post job starts, which steps it processes.
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 the 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.
None warn_overlapping_post_recipes(str run_dir, dict post_cfg, dict plan, post_stages)
Warn when another recipe in this run already produced outputs this one repeats.
"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.
int select_existing_run_petsc_index_bytes(str run_dir, args=None)
Select the PetscInt width an existing run was staged with, for its later stages.
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.
None _refuse_detached_source_checkout(str source_project_root, str action)
Refuse a branch pull when the source checkout is pinned to a detached commit.
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.
str _summary_table_path(dict context, str name)
Resolve a summarize --table name to the CSV it prints.
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...
"tuple[str, str | None]" slurm_job_end_state(str job_id)
Whether a recorded Slurm job (or array) is still queued, and how it ended if not.
_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.
dict resolve_reference_scales(dict case_cfg)
Resolve the reference length and velocity (and density, when given) of a case.
None ensure_workspace_layout(str workspace_root)
Materialize the uniform, cheap directory skeleton for one workspace.
list build_identity_problems(dict identities, workspace_requirement=None, "dict | None" source_identity=None)
Report every reason the active build identity is not internally coherent.
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.
int runtime_petsc_index_bytes()
Bytes of a PetscInt in the PETSc the solver this conductor drives was built on.
_field_statistics_window_samples(str run_dir, int step)
Read each field-statistics window's sample count from one committed checkpoint.
"dict | None" resolve_les_diagnostics(dict case_cfg, dict monitor_cfg, bool warn=False)
Resolve the LES diagnostic log settings from monitor.yml or their deprecated location.
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.
None _write_pvd_collection(str path, list entries)
Atomically write one VTK Collection file.
_set_submission_stage_metadata(dict target_context, str stage_name, dict stage_meta)
Persist one stage's metadata back into the submission payload.
_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.
None record_run_petsc_index_bytes(str run_dir)
Record in config/active.json the PetscInt width the current command selected.
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 _existing_pvd_times(str pvd_path)
Read step-to-time fallbacks from an existing PVD collection.
"tuple[int, str]" required_petsc_index_bytes(dict case_cfg, str case_path, "dict | None" monitor_cfg, "str | None" staged_grid=None)
The PetscInt width a new run needs, from its particle count and grid size.
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.
"dict | None" read_binary_petsc_stamp(str executable_path)
Read the PETSc build stamp from an executable's bytes, without running it.
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.
int finish_post_job(str run_dir, str post_recipe_path, bool exited_cleanly=False)
Record, after a post job ends, which steps it produced.
dict restart_grid_inputs(str run_dir, dict case_cfg)
The inputs a run's geometry is derived from, to compare across a restart.
list normalize_monitor_function_names(list names, str field_name)
Translate renamed solver functions in a monitor.yml function list.
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.
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, "tuple | None" identity_check=None)
Render a Slurm batch script for a single command.
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.
list da_processor_layout_problems(dict grid_cfg, "int | None" num_procs, "list | None" block_dims=None, "dict | None" solver_cfg=None, bool any_periodic=False, "str | None" source=None)
Problems with a user-set DMDA layout that would abort grid setup.
bool make_args_include_explicit_goal("list[str]" make_args)
Return True when make args contain an explicit target rather than only options/assignments.
warn_on_restart_grid_change(dict source_inputs, dict branch_inputs)
Warn at staging when a restart's grid inputs differ from its source run's.
str _post_finalize_python_source()
Return the importable serial finalizer used by generated jobs.
_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.
dict validate_petsc_vec_binary(str path, "int | None" index_bytes=None)
Validate the basic PETSc binary VecView envelope used by ReadFieldData.
_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.
None run_project_make(str source_project_root, "list[str]" make_args)
Run the project Makefile with caller-supplied arguments, defaulting to all.
dict snapshot_run_configuration(str run_dir, dict source_paths, bool continuation=False)
Snapshot editable YAML inputs without erasing prior continuation state.
int adopt_relocated_artifact(str root, str kind="run", bool dry_run=False)
Point a moved run's or study's generated files at where it now lives.
str get_post_step_list_path(str run_dir, dict post_cfg)
Return the file naming the steps a launched post job processes.
list petsc_index_arguments(int index_bytes)
Generator arguments naming a PetscInt width; none for PETSc's default 32-bit.
_read_runtime_diagnostics_csv(path)
Yields (segment, row) for each data line of one of the solver's CSV records.
_find_named_file_upwards(str start, str filename)
Find a named file at or above an arbitrary filesystem anchor.
"tuple[dict, dict, dict]" _load_post_job_configs(str run_dir, str post_recipe_path)
Load the configuration a post job needs from the run it runs in.
str normalize_solution_convergence_mode(str value)
Normalizes the solution-convergence mode selector to the C-side canonical string.
list executable_identity_check_lines(str executable, "str | None" expected_version_line, bool pinned=False)
Shell lines that compare an executable's reported identity with the staged one.
"list[tuple[int, str, list]]" _newton_summary_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Newton per-step summary.
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.
dict build_software_lock("str | None" run_dir=None)
Capture the exact software identity a run is about to execute with.
dict plan_run_executable_paths(str run_dir, args, "str | None" workspace_root)
Predict, without writing, which executables staging would give a run.
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 that runs one post job under an exclusive 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 _print_petsc_build(dict identity)
Print the PETSc an executable was built against and the library it would load.
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.
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.
dict resolve_decomposition(int num_procs, dict grid_cfg, "list | None" block_dims, "dict | None" solver_cfg, bool any_periodic, explicit=None, "dict | None" cluster_cfg=None)
Resolve the MPI decomposition a stage launches with.
_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.
str resolve_path(str anchor_file, str candidate)
Resolve a potentially relative path against a source YAML file path.
normalize_ic_initial_spectra(requested, excluded_axes, generator, required)
Validate the spectra a structured IC provider measures on its own output.
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.
initial_condition_diagnostic_paths(run_dir, resolved_ic)
Resolve spectral IC artifacts through the canonical run layout, also for dry runs.
list preflight_config_directories(str root_dir)
Every config directory under a run or study root that may hold a control file.
dict parse_solver_config(dict solver_cfg, dict scales=None, dict case_cfg=None)
Parses the structured solver.yml into a flat dictionary of {flag: value}.
None _require_clean_source_checkout(str action)
Refuse version-changing Git operations in a dirty source checkout.
list _normalize_post_statistics_ensembles(raw)
Validate and canonicalize the ensembles a post recipe defines.
_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)
Read the runtime memory record into rows by step and its 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.
"str | None" executable_petsc_library_dir(str executable_path)
The lib/ directory of the PETSc an executable was built against, from its stamp.
"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.
dict resolve_post_decomposition(str run_dir, int num_procs, explicit=None, "dict | None" cluster_cfg=None)
Resolve the postprocessor's MPI decomposition from the run's staged configuration.
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.
"int | None" checkpoint_source_petsc_index_bytes(str source_dir)
PetscInt width the committed checkpoints under a run output root were written with.
_normalize_statistics_requests(str label, fields_raw, covariances_raw, dict eligible, case_cfg=None)
Validate and canonicalize the fields and covariances one window or ensemble requests.
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)
Read the per-block momentum records (Picard history, Newton summary) into rows by step.
"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)'.
None report_post_plan(dict plan)
Print which steps a post plan processes, keeps, and waits for.
validate_simulation_configs(dict case_cfg, dict solver_cfg, dict monitor_cfg, str case_path, str solver_path, str monitor_path, bool restart_source_given=False)
Validates every configuration a simulation run consumes, before any work is done.
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.
list _runtime_memory_rows(str path)
Return the runtime memory record's (segment, row) pairs (CSV, or an older run's text log).
"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.
dict resolve_ic_cli_params(dict ic, int finit_code, prepared_blocks, dict scales)
Resolve all IC parameters and return a dict of PETSc option values.
dict _summary_scalar_transport(dict context, dict solver_cfg)
Scalar-transport parameters as the run resolves them, wherever they are set.
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.
list list_rendered_template_files(str rendered_root)
List the template-managed files of a rendered workspace, workspace-relative.
int normalize_les_averaging_mode(value)
Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
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.
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, "str | None" expected_version_line=None)
Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
append_les_parameter_flags(dict les_cfg, list control_lines)
Appends the LES closure parameter flags from a structured les block.
"tuple[list, dict]" build_post_locked_command(str run_dir, str recipe_fingerprint, list wrapped_command, bool create_wrapper=True, "list | None" success_command=None, "str | None" post_recipe=None, "str | None" postprocessor=None, bool recompute=False)
Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
str _format_optional_step(step)
Format an optional step number for user-facing diagnostics.
bool case_declares_periodic_axis(dict case_cfg)
Whether any face of any block is PERIODIC, which sets the DMDA stencil width.
"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.
dict runtime_build_identities("dict | None" source_identity=None, "str | None" run_dir=None)
Read the build identity of every native executable a run would launch.
dict load_run_executables(str run_dir)
Return the executables a run is pinned to, as recorded by its active configuration.
str resolve_run_output_dir(str run_dir, dict monitor_cfg)
Resolve the output data directory within a run directory.
int select_petsc_index_bytes(int index_bytes, str reason)
Make the current command drive the build of one PetscInt width.
"str | None" classify_post_step(record, files, commit, str recipe, bool recompute=False)
Decide whether one step's existing output is still valid.
bool discard_unused_run_directory(str run_dir, *bool created, bool staged=False)
Remove a generated run directory that never received any content.
"tuple[str | None, list]" _solution_convergence_rows(str log_dir)
Return the solution-convergence record's path and its (segment, row) pairs.
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.
dict render_template_workspace(str template_dir, str template_name, str destination)
Render an example template into the canonical workspace layout at a new path.
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)
Read the per-block Poisson histories into each step's last iteration.
sweep_workflow(args)
Study/sweep orchestration using Slurm job arrays.
list finalize_post_paraview_series(str run_dir, dict post_cfg)
Build physical-time ParaView collections from current or lineage VTK output.
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.
list run_post_reducers_stage(str run_dir, dict post_cfg, bool quiet=False)
Run every reducer task over the profiles the recipe has written.
_render_summary_plot_catalog(list catalog, str output_format)
Render available summarize plot-series metadata.
float reference_scale(tuple dimension, dict scales)
Return the factor that turns a physical quantity of this dimension into solver units.
dict _read_checkpoint_options(str metadata_path)
Parse the deliberately small PETSc-options checkpoint manifest.
inputs_workflow(args)
Handle explicit workspace input management.
_mapping_value_with_aliases(dict mapping, *keys, default=None)
Return the first defined value from a mapping across alias keys.
"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.
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.
"dict | None" _parse_binary_petsc_line(str line)
Parse the PETSc build line a native executable reports.
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.
choose_decomposition(int num_procs, list block_dims, "dict | None" solver_cfg, bool any_periodic)
Choose the MPI decomposition of a structured grid for a rank count.
int normalize_les_filter_width(value)
Maps LES grid-filter-width model names to the C -les_filter_width flag.
str _resolve_version_ref(str version)
Resolve a user-named version to a Git ref that exists in the source checkout.
tuple resolve_unsafe_paths_override(dict dirs, str monitor_path)
Resolve the unsafe-paths override, requiring a real YAML boolean.
float _checkpoint_time_for_series(str run_dir, int step, dict fallback, float time_scale=1.0)
Read the authoritative time of one visualization frame.
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.
dict normalize_post_reducers_config(dict post_cfg)
Validate and canonicalize the reducers block of post.yml.
"list[tuple[int, str]]" _block_family_paths(str log_dir, *str bases)
Return (block, path) for each per-block solver record, CSV preferred per block.
list normalize_particle_fields(fields_cfg)
Validate models.physics.particles.fields and lower each value to expressions.
append_turbulence_flags(dict models, list control_lines, dict scales=None)
Appends turbulence model flags from legacy or structured case.yml blocks.
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.
dict apply_run_executable_pinning(str run_dir, str revision, args, "str | None" workspace_root)
Pin, keep, or refuse the run's executables before anything records them.
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.
str run_initial_spectrum_generator(str field_path, str staged_grid, str spectrum_path, str case_dir, task_cfg=None)
Measure the shell-averaged spectrum of a staged initial condition.
dict append_grid_da_processor_layout(list control_lines, dict grid_cfg, int num_procs, "list | None" block_dims=None, "dict | None" solver_cfg=None, bool any_periodic=False, explicit=None, "dict | None" cluster_cfg=None)
Resolve the solver's MPI decomposition, report it, and append its flags.
str rebuild_command(str name, dict identity)
The command that rebuilds one native executable.
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.
list _merge_spectra_rows(str path, tuple fieldnames, set remeasured, list new_rows)
Combine newly measured spectra rows with those already written for other steps.
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.
dict resolve_scalar_transport(dict case_cfg, dict solver_cfg, bool warn=False)
Resolve the scalar-transport parameters from case.yml or their deprecated location.
dict resolve_resampled_velocity_params(dict params, prepared_blocks, dict scales, provider_context=None)
Validate resampled_velocity parameters and convert them to the provider contract.
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.
None normalize_decomposition_args(args)
Resolve -n and the optional --ni/--nj/--nk of picurv run into one rank count.
float input_reference_scale(tuple path, dict scales)
Return the reference scale of one recorded input, for converting a whole payload.
dict resolve_initial_condition_config(dict ic, prepared_blocks, dict scales, provider_context=None)
Resolve legacy and structured initial-condition YAML into one launcher contract.
int payload_petsc_index_bytes(str path)
PetscInt width of a PETSc payload: as its checkpoint recorded it, else the driven solver's.
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.
append_les_diagnostics_flags("dict | None" diagnostics, list control_lines)
Append the LES diagnostic log flags resolved by resolve_les_diagnostics().
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.
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.
str resolve_runtime_executable(str executable_name)
Resolve solver/post executable path, preferring local sibling binaries.
dict picurv_build()
The conductor's own build identity, resolved on first use and kept for the process.
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.
dict build_post_execution_plan(str run_dir, str run_id, dict case_cfg, dict monitor_cfg, dict post_cfg, bool recompute=False, bool allow_source_frontier_scan=True, "str | None" postprocessor_sha=None)
Decide which steps of a recipe's window one invocation processes.
str generate_post_recipe_file(str run_dir, str run_id, dict post_cfg, dict source_files, monitor_cfg=None, "dict | None" decomposition=None)
Generates a key=value config file (post.run) for the C post-processor.
"list[tuple[int, str, list]]" _poisson_history_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Poisson iteration history.
_list_summary_tables(dict context, str output_format)
Print the summarize --table names whose CSV this run has, with row counts.
"str | None" _log_family_path(str log_dir, str base)
Return a solver record's CSV, or the text log an older run wrote instead.
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.
build_project(args)
Implements the 'build' command.
dict _decomposition_layout(layout)
Express an (ni, nj, nk) tuple as the da_processors mapping the layout checks read.
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.
bool petsc_library_mismatch("dict | None" petsc, "str | None" library)
Whether the library the loader resolves lies outside the PETSc the binary was built against.
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.
dict run_post_spectra_stage(str run_dir, dict post_cfg, dict monitor_cfg, str source_dir, steps, bool quiet=False, "dict | None" plan=None, bool recompute=False)
Measure spectra for every requested task across a window of committed steps.
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.
"tuple[str | None, list]" _continuity_rows(str log_dir)
Return the continuity record's path and its (segment, row) pairs.
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.
None finalize_post_outputs(str run_dir, dict post_cfg, stages)
Run every serial step that reads a successful field stage's output.
int normalize_les_test_filter(value)
Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
"tuple[dict, list[int]]" _parse_continuity_metrics_log(str log_dir)
Read the continuity record into rows by step (one per block) plus observed order.
str normalize_les_averaging_directions(value)
Maps a list of homogeneous logical directions to the C flag's string form.
tuple unused_asset_kinds(dict case_cfg, dict solver_cfg)
Asset kinds a run with these configurations would build but never read.
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.
float ic_payload_divisor(dict resolved_ic, str case_dir)
Return the factor a file-backed initial condition is divided by when staged.
restore_continuation_records(str run_dir, dict saved, str revision)
Undo a refused continuation's staging: restore its records, drop its revision.
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 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.
"list | None" resolve_grid_node_dims(dict case_cfg, str case_path, "str | None" staged_grid=None)
Per-block (IM, JM, KM) node counts the solver will read, without building the grid.
str describe_decomposition(dict resolved, int num_procs)
One line stating the decomposition a stage launches with.
to_solver_units(value, tuple path, dict scales)
Convert one physical configuration value, or a list of them, to solver units.
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.
int _lineage_reset_index(list segments, str family)
Return the first segment belonging to one continuous artifact family.
dict plan_run_assets(dict case_cfg, str case_path, skip_kinds=())
Plan reuse/build/runtime actions for all configured run providers.
dict resolve_profiling_config(dict monitor_cfg)
Resolve profiling reporting config from monitor.yml.
_render_summary_table(dict context, str name, last_n, columns, str output_format)
Print the last rows of one of the run's CSV records as aligned columns (or JSON).
list build_spectra_follow_command(str run_dir, str post_path, dict post_cfg, bool recompute=False)
Build the batch-script step that measures spectra after the field stage.
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.
list physical_units_transition_notices(dict case_cfg, dict solver_cfg, dict monitor_cfg)
Name the inputs whose meaning changed when every input became physical.
"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.
_checkpoint_commit(str source_dir, int step)
Identify one committed checkpoint by its commit marker.
dict write_scaled_petsc_vec_binary(str source, str destination, float divisor)
Copy a PETSc binary Vec, dividing every scalar by one factor.
"list[str]" _find_solver_stream_log_candidates(str run_dir, str log_dir)
Return plausible solver stream logs for local and Slurm runs.
list petsc_library_path_lines("str | None" library_dir)
Shell lines putting one PETSc's lib/ first on LD_LIBRARY_PATH, after module setup.
None validate_programmatic_bridge_grid_settings(dict raw_settings)
Validate the scalar programmatic grid settings the bridge PICGRID is built from.
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.
persist_post_resume_state(str run_dir, dict plan, steps=None, spectra=None)
Write a recipe's state: the window last planned and its per-step output records.
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.
"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.
"list[tuple[int, str, list]]" _picard_history_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Picard momentum history.
_invoke_plot_gen(dict request)
Invoke standalone plot.gen with one normalized request over stdin.
int _post_window_derived_field_count(dict window_cfg, list outputs, dict eligible=None)
Count the derived fields one window would produce for a set of outputs.
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.
_find_stored_asset(str workspace_root, dict provider)
Find a published object built from exactly this provider's inputs.
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.
dict ic_provider_params_to_solver_units(str generator, dict params, dict scales)
Convert a provider's validated physical parameters to solver units.
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.
_typed_csv_value(str text)
Return a CSV cell as a float (written with a point or exponent), an int, or the text.
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.
dict materialize_run_assets(str run_dir, dict case_cfg, str case_path, bool require_precomputed=False, bool fetch_missing=False, skip_kinds=())
Resolve/build workspace assets and write the exact run input lock.
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.
"int | None" requested_petsc_index_bytes(args=None)
The index width a command was told to use: --petsc-index-bits, else PICURV_PETSC_INDEX_BITS.
list reject_generator_destination_keys(generator, str case_path, str label)
Reject generator settings that try to choose their own output destination.
check_restart_continuation(str solver_exe, list solver_args, str run_dir, str config_search_anchor=None)
Ask the simulator whether a staged restart's carried statistics windows can continue.
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.
"list[tuple[int, str, list]]" _newton_history_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Newton iteration history.
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.
sync_case_template_files(str case_dir, str template_dir, bool overwrite=False, bool prune=False, managed_rel_paths=None, str template_name=None)
Sync template files into a case directory, preserving modified files unless overwrite is requested.
bool _statistics_subsystem_available(dict case_cfg, requirement)
Report whether the subsystem a statistics field depends on is active.
load_generator_module(str script_name, str module_name)
Load one of the extensionless scripts under generators/ as a module.
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 | None" _git_output(*arguments)
Run one git command in the source checkout.
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.
"set[int]" _scan_post_statistics_csv_steps(str csv_path)
Scan step ids from the first CSV column of a statistics artifact.
list resolve_run_lineage(str run_dir, str scope="lineage")
Return root-to-leaf run segments, for a ParaView collection or a reducer's metrics.
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.
"dict | None" _decomposition_record("dict | None" resolved)
The JSON-serializable record of a resolved decomposition for run metadata.
get_post_statistics_task_tokens(dict post_cfg)
Return normalized statistics pipeline tokens that will be written into post.run.
"dict[int, str]" _scan_post_vtk_steps(str prefix_path, str extension)
Collect the VTK files named with a prefix, step suffix, and extension.
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.
None run_post_finalize_locked(str run_dir, str recipe_fingerprint, list finalize_command)
Refresh a collection-only result under the ordinary post writer lock.
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 run_executable_path("str | None" run_dir, str name)
Resolve the executable a run launches: its pinned copy, else the installation's.
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.
"list[tuple]" collect_post_completion_families(str run_dir, dict post_cfg, monitor_cfg=None)
Collect the output files each output family of the current recipe holds per step.
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.
tuple input_quantity(tuple path)
Return the (dimension, conversion site) recorded for one configuration input.
render_run_dry_plan(dict plan, str output_format="text")
Render dry-run plan in human or JSON format.
list resolve_traced_particle_ids(dict monitor_cfg)
Return the sorted, distinct particle IDs of logging.trace_particles.
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.
_recorded_run_root(str run_dir)
Return the location a run's generated files refer to.
"dict | None" _read_binary_build_stamp(str executable_path)
Read the build identity stamped into an executable's bytes, without running it.
_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.
list _collect_profile_plot_records(dict context)
Collect the profile histories the profile_series reducer wrote.
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.
quantity_to_solver_units(value, tuple quantity, dict scales, str label)
Convert a physical value, or a list of them, by one recorded quantity entry.
_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.
validate_wall_model_pairing(dict case_cfg, les_cfg, wall_cfg, str case_path, list errors, list warnings)
Rejects wall-model selections that no turbulence treatment can support.
int normalize_interpolation_method(str value)
Maps interpolation method names to C enum/int codes (-interpolation_method).
dict capture_continuation_records(str run_dir, str restart_dir)
Save what a continuation's staging changes, so a refusal can undo it.
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.
int select_run_petsc_index_bytes(args, dict case_cfg, str case_path, "dict | None" monitor_cfg, "str | None" restart_source_dir=None)
Choose and select the PetscInt width a run's solve stage needs.
dict _toolchain_identity()
Best-effort record of the PETSc, MPI, and compiler in the shell staging the run.
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.
"tuple[dict, list[int]]" _parse_particle_metrics_log(str log_dir, str metrics_dir)
Read the per-step particle record into rows by step plus observed order.
bool resolve_executable_pinning(args, "str | None" workspace_root)
Decide whether staging pins the installation's executables into the run.
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.
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.
dict normalize_post_paraview_series_config(dict post_cfg)
Normalize the optional ParaView collection policy in a post recipe.
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 resolve_sweep_stage_executables(list run_dirs)
Choose the executables an array stage launches for every member of a study.
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.
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.
"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.
dict pin_run_executables(str run_dir, str revision)
Copy the installation's executables into a run's configuration revision.
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 build_post_finalize_command(str run_dir, str archived_post_path, dict post_cfg, post_stages=POST_STAGE_NAMES)
Build the serial post-success command for staged execution.
"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.
None _check_file_ic_matches_grid(dict payload, str staged_grid, dict resolved_ic)
Refuse a mode: file payload sized for another grid, and say what imports one.
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.
run_workflow(args)
Main orchestrator for the 'run' command (local and Slurm modes).
bool initial_condition_is_authoritative(dict case_cfg, dict solver_cfg)
Whether the configured initial condition is the field the solver starts from.
dict resolve_runtime_execution_context(dict runtime_execution_cfg, str context)
Resolve default plus context-specific execution overrides.
int resolve_post_owned_start(str run_dir, dict post_cfg, int requested_start, int step_interval)
Intersect a recipe's logical cadence with this run's branch.
pull_source_repo(args)
Refresh source branches in the repository resolved from a case directory.
"tuple[dict, dict]" _read_post_records(str state_path)
Read the per-step output records a recipe's state holds.
"tuple[dict, str]" apply_canonical_post_paths(dict post_cfg, str run_dir)
Route every post artifact into its fixed analysis or visualization home.
str _resolve_lineage_parent_run(str child_run_dir, dict lineage)
Resolve and identity-check one recorded lineage parent.
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.