PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
core.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3
4"""!
5@file core.py
6@brief A comprehensive conductor script for the PICurv simulation platform.
7
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.
15"""
16
17import yaml
18import sys
19import os
20import argparse
21import subprocess
22import shutil
23import glob
24import csv
25import json
26import hashlib
27import itertools
28import re
29import shlex
30import copy
31import math
32import difflib
33import warnings
34from datetime import datetime
35import time
36import filecmp
37import importlib.util
38import errno
39import tempfile
40from pathlib import Path
41
42try:
43 from .storage import (
44 StorageError,
45 cold_study_members,
46 restore_cold_study_members,
47 is_artifact_cold,
48 read_artifact_identity,
49 STORAGE_LOCK_FILENAME,
50 STORAGE_STATE_FILENAME,
51 require_storage_payload_local,
52 runtime_stage_lock,
53 storage_state_summary,
54 )
55except ImportError:
56 # White-box tests also load core.py directly rather than as a package module. The
57 # storage package resolves its own siblings by relative import, so it is loaded by
58 # putting its parent on the path rather than by file location.
59 _package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
60 if _package_parent not in sys.path:
61 sys.path.insert(0, _package_parent)
62 _storage_module = importlib.import_module("picurv_cli.storage")
63 StorageError = _storage_module.StorageError
64 cold_study_members = _storage_module.cold_study_members
65 restore_cold_study_members = _storage_module.restore_cold_study_members
66 is_artifact_cold = _storage_module.is_artifact_cold
67 read_artifact_identity = _storage_module.read_artifact_identity
68 STORAGE_LOCK_FILENAME = _storage_module.STORAGE_LOCK_FILENAME
69 STORAGE_STATE_FILENAME = _storage_module.STORAGE_STATE_FILENAME
70 require_storage_payload_local = _storage_module.require_storage_payload_local
71 runtime_stage_lock = _storage_module.runtime_stage_lock
72 storage_state_summary = _storage_module.storage_state_summary
73
74_NUMPY_MODULE = None
75_MATPLOTLIB_PYPLOT = None
76
77
79 """!
80 @brief Module-like proxy that preserves `picurv.np` without eager import.
81 """
82
83 def __getattr__(self, name):
84 """!
85 @brief Resolve a NumPy attribute on first use.
86 @param[in] name NumPy attribute name.
87 @return Requested NumPy attribute.
88 """
89 return getattr(require_numpy(), name)
90
91
93
94
96 """!
97 @brief Remove site-package paths for a different Python major/minor version.
98 @param[in] paths Candidate sys.path entries.
99 @return Filtered path list.
100 """
101 current = (sys.version_info[0], sys.version_info[1])
102 pattern = re.compile(r"python(?:-)?(\d+)\.(\d+)", re.IGNORECASE)
103 filtered = []
104 for path in paths:
105 text = str(path)
106 match = pattern.search(text)
107 if match:
108 path_version = (int(match.group(1)), int(match.group(2)))
109 if path_version != current and ("site-packages" in text or "dist-packages" in text):
110 continue
111 filtered.append(path)
112 return filtered
113
114
115def _drop_imported_package(package_name: str):
116 """!
117 @brief Remove a failed/partial import package tree from sys.modules.
118 @param[in] package_name Top-level package name.
119 """
120 prefix = package_name + "."
121 for module_name in list(sys.modules):
122 if module_name == package_name or module_name.startswith(prefix):
123 sys.modules.pop(module_name, None)
124
125
127 """!
128 @brief Import NumPy only for commands that need numeric reductions.
129 @return Imported NumPy module.
130 """
131 global _NUMPY_MODULE
132 if _NUMPY_MODULE is not None:
133 return _NUMPY_MODULE
134 try:
135 import numpy
136 except Exception as exc:
137 first_error = exc
138 original_path = list(sys.path)
139 try:
141 sys.path = _prune_incompatible_python_site_paths(original_path)
142 import numpy
143 except Exception as retry_exc:
144 raise RuntimeError(
145 "NumPy is required for this operation, but no compatible NumPy "
146 "could be imported for this Python interpreter. PICurv ignored "
147 "site-packages paths for other Python versions and retried. "
148 f"First error: {first_error}. Retry error: {retry_exc}"
149 ) from retry_exc
150 finally:
151 sys.path = original_path
152 _NUMPY_MODULE = numpy
153 return _NUMPY_MODULE
154
155
157 """!
158 @brief Import matplotlib.pyplot lazily for study plot generation.
159 @return matplotlib.pyplot when available, otherwise None.
160 """
161 global _MATPLOTLIB_PYPLOT
162 if _MATPLOTLIB_PYPLOT is not None:
163 return _MATPLOTLIB_PYPLOT
164 original_path = list(sys.path)
165 try:
166 import matplotlib.pyplot as pyplot
167 except Exception:
168 try:
169 _drop_imported_package("matplotlib")
170 sys.path = _prune_incompatible_python_site_paths(original_path)
171 import matplotlib.pyplot as pyplot
172 except Exception:
173 return None
174 finally:
175 sys.path = original_path
176 _MATPLOTLIB_PYPLOT = pyplot
177 return _MATPLOTLIB_PYPLOT
178
179# --- Global Path Definitions ---
180# The implementation package and source-tree entrypoint live in picurv_cli/,
181# while generators/ owns standalone generators.
182PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))
183PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)
184INVOKED_SCRIPT_DIR = os.environ.get(
185 "_PICURV_INVOKED_SCRIPT_DIR",
186 PACKAGE_PATH,
187)
188SCRIPT_PATH = os.environ.get(
189 "_PICURV_SCRIPT_PATH",
190 PACKAGE_PATH,
191)
192PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)
193GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT, "generators")
194if os.path.basename(SCRIPT_PATH) == "bin":
195 DEFAULT_BIN_DIR = SCRIPT_PATH
196else:
197 DEFAULT_BIN_DIR = os.path.join(PROJECT_ROOT, "bin")
198
199VERSION_FILE = os.path.join(PACKAGE_PROJECT_ROOT, "VERSION")
200
201
203 """!
204 @brief Read the single release version shared by every PICurv executable.
205 @return Release version from VERSION, or a safe development fallback.
206 """
207 configured = os.environ.get("PICURV_RELEASE_VERSION")
208 if configured:
209 return configured.strip()
210 try:
211 with open(VERSION_FILE, "r", encoding="utf-8") as stream:
212 value = stream.read().strip()
213 except OSError:
214 value = "0.0.0"
215 return value or "0.0.0"
216
217
218def _source_build_identity(release_version: str) -> dict:
219 """!
220 @brief Resolve reproducible release, commit, and dirty-tree build identity.
221 @param[in] release_version Release version read from VERSION.
222 @return Mapping suitable for manifests and user-facing status output.
223 """
224 identity = {
225 "release_version": release_version,
226 "version": release_version,
227 "git_commit": None,
228 "git_short_commit": None,
229 "dirty": None,
230 "build_id": release_version,
231 }
232 try:
233 commit_result = subprocess.run(
234 ["git", "rev-parse", "HEAD"], cwd=PACKAGE_PROJECT_ROOT,
235 text=True, capture_output=True, check=False,
236 )
237 dirty_result = subprocess.run(
238 ["git", "status", "--porcelain", "--untracked-files=no"],
239 cwd=PACKAGE_PROJECT_ROOT, text=True, capture_output=True, check=False,
240 )
241 if commit_result.returncode == 0:
242 commit = commit_result.stdout.strip()
243 identity["git_commit"] = commit
244 identity["git_short_commit"] = commit[:12]
245 if dirty_result.returncode == 0:
246 identity["dirty"] = bool(dirty_result.stdout.strip())
247 except OSError:
248 pass
249 # Commits since the release tag, so a development build is visibly ahead of the
250 # release it names rather than claiming to be that release.
251 identity["dev_distance"] = None
252 identity["released"] = False
253 try:
254 tag_result = subprocess.run(
255 ["git", "describe", "--tags", "--match", f"v{release_version}", "--long"],
256 cwd=PACKAGE_PROJECT_ROOT, text=True, capture_output=True, check=False,
257 )
258 if tag_result.returncode == 0:
259 parts = tag_result.stdout.strip().rsplit("-", 2)
260 if len(parts) == 3 and parts[1].isdigit():
261 identity["dev_distance"] = int(parts[1])
262 else:
263 # No tag for this release yet: every commit is development toward it.
264 count_result = subprocess.run(
265 ["git", "rev-list", "--count", "HEAD"], cwd=PACKAGE_PROJECT_ROOT,
266 text=True, capture_output=True, check=False,
267 )
268 if count_result.returncode == 0 and count_result.stdout.strip().isdigit():
269 identity["dev_distance"] = int(count_result.stdout.strip())
270 except OSError:
271 pass
272 identity["released"] = identity["dev_distance"] == 0 and not identity["dirty"]
273 if identity["git_short_commit"]:
274 development = "" if identity["dev_distance"] in (0, None) else f".dev{identity['dev_distance']}"
275 suffix = f"{development}+g{identity['git_short_commit']}"
276 if identity["dirty"]:
277 suffix += ".dirty"
278 identity["build_id"] = release_version + suffix
279 identity["version"] = identity["build_id"]
280 return identity
281
282
283PICURV_RELEASE_VERSION = _read_release_version()
284PICURV_BUILD = _source_build_identity(PICURV_RELEASE_VERSION)
285PICURV_VERSION = PICURV_BUILD["version"]
286CASE_ORIGIN_METADATA_FILENAME = ".picurv-origin.json"
287WORKSPACE_CONFIG_FILENAME = ".picurv-workspace.yml"
288WORKSPACE_SCHEMA_VERSION = 1
289RUN_MANIFEST_SCHEMA_VERSION = 3
290ASSET_MANIFEST_SCHEMA_VERSION = 1
291ASSET_LOCK_SCHEMA_VERSION = 1
292RUNTIME_EXECUTION_CONFIG_FILENAME = ".picurv-execution.yml"
293LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME = ".picurv-local.yml"
294RUNTIME_EXECUTION_EXAMPLE_FILENAME = "execution.example.yml"
295RUNTIME_EXECUTION_CONFIG_FILENAMES = (
296 RUNTIME_EXECUTION_CONFIG_FILENAME,
297 LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME,
298)
299
300DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE = """# Optional shared runtime launcher overrides.
301# This file is safe to leave unchanged on ordinary local machines.
302# Edit it only when your site needs custom MPI launcher tokens.
303#
304# Precedence:
305# - local/login-node runs: local_execution -> default_execution -> built-in mpiexec
306# - generated cluster jobs: cluster.yml.execution -> cluster_execution -> default_execution -> built-in srun
307#
308# Example override:
309# default_execution:
310# launcher: "mpirun"
311# launcher_args:
312# - --bind-to
313# - none
314default_execution: {}
315
316local_execution: {}
317
318cluster_execution: {}
319"""
320
321CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT = "my_project_account"
322CLUSTER_TEMPLATE_PLACEHOLDER_MAIL = "user@example.edu"
323
324DEFAULT_WALLTIME_GUARD_POLICY = {
325 "enabled": True,
326 "warmup_steps": 10,
327 "multiplier": 2.0,
328 "min_seconds": 60.0,
329 "estimator_alpha": 0.35,
330}
331WALLTIME_GUARD_ENV_JOB_START_EPOCH = "PICURV_JOB_START_EPOCH"
332WALLTIME_GUARD_ENV_LIMIT_SECONDS = "PICURV_WALLTIME_LIMIT_SECONDS"
333POST_RESUME_STATE_FILENAME = "post.resume.json"
334POST_LOCK_FILENAME = "post.lock"
335POST_LOCK_METADATA_FILENAME = "post.lock.json"
336POST_LOCK_WRAPPER_FILENAME = "post_lock_wrapper.py"
337POST_RESUME_SCHEMA_VERSION = 1
338POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {"startTime", "endTime"}
339CHECKPOINT_FORMAT = "picurv-checkpoint"
340CHECKPOINT_VERSION = 1
341CHECKPOINT_STEP_WIDTH = 12
342CHECKPOINT_REQUIRED_EULERIAN_FIELDS = {"Ucat", "Ucont", "Ucont_rm1", "P", "Nvert"}
343
344WORKSPACE_DIRECTORY_LAYOUT = (
345 "config",
346 "config/studies",
347 "config/grids",
348 "config/initial_conditions",
349 "config/inlet_profiles",
350 "inputs",
351 "inputs/grids",
352 "inputs/initial_conditions",
353 "inputs/inlet_profiles",
354 "inputs/reference_fields",
355 "assets",
356 "assets/objects",
357 "assets/objects/grids",
358 "assets/objects/initial_conditions",
359 "assets/objects/inlet_profiles",
360 "assets/sets",
361 "runs",
362 "studies",
363)
364
365RUN_DIRECTORY_LAYOUT = (
366 "config",
367 "config/history",
368 "config/post-recipes",
369 "inputs",
370 "inputs/grid",
371 "inputs/initial_condition",
372 "inputs/inlet_profiles",
373 "inputs/restart",
374 "output",
375 "output/checkpoints",
376 "output/analysis",
377 "output/analysis/metrics",
378 "output/analysis/statistics",
379 "output/analysis/spectra",
380 "output/analysis/plots",
381 "output/visualization",
382 "logs",
383 "scheduler",
384)
385
386CANONICAL_RUN_PATHS = {
387 "config": "config",
388 "inputs": "inputs",
389 "restart": "inputs/restart",
390 "output": "output",
391 "checkpoints": "output/checkpoints",
392 "analysis": "output/analysis",
393 "metrics": "output/analysis/metrics",
394 "statistics": "output/analysis/statistics",
395 "spectra": "output/analysis/spectra",
396 "plots": "output/analysis/plots",
397 "visualization": "output/visualization",
398 "logs": "logs",
399 "scheduler": "scheduler",
400}
401
402#: Run-relative path of the staged initial-condition spectrum. It sits beside the
403#: post-produced spectra but is a reference overlay, not a selectable spectrum task.
404INITIAL_CONDITION_SPECTRUM_RELPATH = os.path.join(
405 CANONICAL_RUN_PATHS["spectra"], "initial_condition_spectrum.csv"
406)
407
408_WORKSPACE_ARTIFACT_ROOT_VALUES = {"runs", "studies"}
409_ASSET_SOURCE_REFERENCE_KEYS = {
410 "source_file", "path", "config_file", "field_file", "grid_file", "source_case", "script"
411}
412_PYTHON_INITIAL_CONDITION_PROVIDERS = {"ic_gen", "spectral_random_velocity"}
413_FILE_BACKED_GRID_VALUES = {"file", "grid_gen"}
414_WORKSPACE_MANAGED_PATHS = {"assets", "inputs", "runs", "studies"}
415_VENDORABLE_CONFIG_REFERENCE_KEYS = {"config_file", "script"}
416_PLAIN_FILENAME_SENTINELS = {"", ".", ".."}
417_VERSION_BUILD_ACTIONS = {"install", "activate"}
418
419
420def _find_named_file_upwards(start: str, filename: str):
421 """!
422 @brief Find a named file at or above an arbitrary filesystem anchor.
423 @param[in] start File or directory from which to search.
424 @param[in] filename Basename to locate.
425 @return Absolute path when found, otherwise None.
426 """
427 current = os.path.abspath(start or os.getcwd())
428 if os.path.isfile(current):
429 current = os.path.dirname(current)
430 while True:
431 candidate = os.path.join(current, filename)
432 if os.path.isfile(candidate):
433 return candidate
434 parent = os.path.dirname(current)
435 if parent == current:
436 return None
437 current = parent
438
439
441 """!
442 @brief Locate the nearest initialized PICurv workspace for supplied anchors.
443 @param[in] anchors Candidate config, run, study, or current-working-directory paths.
444 @return Workspace root path when found, otherwise None.
445 """
446 for anchor in anchors or (os.getcwd(),):
447 if not anchor:
448 continue
449 found = _find_named_file_upwards(str(anchor), WORKSPACE_CONFIG_FILENAME)
450 if found:
451 return os.path.dirname(found)
452 return None
453
454
455def load_workspace_config(workspace_root: str) -> dict:
456 """!
457 @brief Load and validate the immutable workspace identity/configuration file.
458 @param[in] workspace_root Initialized workspace directory.
459 @return Parsed workspace configuration mapping.
460 """
461 path = os.path.join(os.path.abspath(workspace_root), WORKSPACE_CONFIG_FILENAME)
462 payload = read_yaml_file(path)
463 if payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION:
464 raise ValueError(
465 f"{path}: unsupported workspace schema_version "
466 f"{payload.get('schema_version')!r}; expected {WORKSPACE_SCHEMA_VERSION}."
467 )
468 errors: list = []
469 _validate_yaml_schema_keys(payload, _WORKSPACE_SCHEMA, path, errors)
470 if errors:
471 raise ValueError("\n".join(errors))
472 return payload
473
474
475def enforce_workspace_version(workspace_root: str) -> dict:
476 """!
477 @brief Enforce an optional workspace PICurv version requirement.
478 @param[in] workspace_root Initialized workspace directory.
479 @return Current build identity after successful validation.
480 """
481 payload = load_workspace_config(workspace_root)
482 software = payload.get("software") or {}
483 requirement = software.get("picurv") if isinstance(software, dict) else None
484 if requirement in (None, ""):
485 return dict(PICURV_BUILD)
486 try:
487 from packaging.specifiers import SpecifierSet
488 from packaging.version import Version
489 requirement_text = str(requirement).strip()
490 if not any(token in requirement_text for token in "<>=!~"):
491 requirement_text = "==" + requirement_text
492 matches = Version(PICURV_RELEASE_VERSION) in SpecifierSet(requirement_text)
493 except Exception as exc:
494 raise ValueError(
495 f"{WORKSPACE_CONFIG_FILENAME}: software.picurv={requirement!r} is not a valid "
496 "version or version range."
497 ) from exc
498 if not matches:
499 raise ValueError(
500 f"Workspace requires PICurv {requirement!r}, but the active release is "
501 f"{PICURV_RELEASE_VERSION} (build {PICURV_BUILD['build_id']}).\n"
502 f"Activate a matching installation with 'picurv versions activate <version>' "
503 "and retry.\n"
504 f"Note: {PACKAGE_PROJECT_ROOT} is a single shared installation. Activating "
505 "re-points every workspace and every unpinned job that resolves executables "
506 "from it, so two workspaces pinned to different releases cannot both be "
507 "satisfied at once. Pin a case's executables with 'picurv init --pin-binaries' "
508 "when it must survive an activation."
509 )
510 return dict(PICURV_BUILD)
511
512
513def enforce_reproducibility_policy(workspace_root: str) -> dict:
514 """!
515 @brief Enforce an optional workspace policy demanding a clean, released build.
516
517 @details Exploratory work should stay frictionless, so this is opt-in: a workspace
518 running a campaign that will be published sets it once, and PICurv then
519 refuses to stage from a modified or untagged tree instead of recording the
520 compromise in a manifest nobody reads until later.
521 @param[in] workspace_root Initialized workspace directory, or None.
522 @return The resolved policy mapping, empty when none is configured.
523 @throws ValueError when the active build does not satisfy the configured policy.
524 """
525 if not workspace_root:
526 return {}
527 payload = load_workspace_config(workspace_root)
528 policy = payload.get("reproducibility") or {}
529 if not isinstance(policy, dict) or not policy:
530 return {}
531 if policy.get("require_clean_release"):
532 problems = []
533 if PICURV_BUILD.get("dirty"):
534 problems.append("the source tree has uncommitted changes")
535 if PICURV_BUILD.get("dev_distance"):
536 problems.append(
537 f"HEAD is {PICURV_BUILD['dev_distance']} commit(s) past the "
538 f"v{PICURV_RELEASE_VERSION} release tag"
539 )
540 elif PICURV_BUILD.get("dev_distance") is None:
541 problems.append("the release tag could not be resolved from this checkout")
542 if problems:
543 raise ValueError(
544 f"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.require_clean_release, "
545 f"but this build is {PICURV_BUILD['build_id']}: " + "; ".join(problems) + ".\n"
546 "Commit and tag the release, or clear the policy for exploratory work."
547 )
548 if policy.get("pin_executables"):
549 stale = [
550 name for name, identity in sorted(runtime_build_identities().items())
551 if identity.get("available") and not identity.get("matches_source")
552 ]
553 unavailable = [
554 name for name, identity in sorted(runtime_build_identities().items())
555 if not identity.get("available")
556 ]
557 if stale or unavailable:
558 detail = []
559 if stale:
560 detail.append("built from another revision: " + ", ".join(stale))
561 if unavailable:
562 detail.append("no build identity available: " + ", ".join(unavailable))
563 raise ValueError(
564 f"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.pin_executables, but "
565 "the executables do not match the active source (" + "; ".join(detail) + ").\n"
566 "Run 'make all' so the run records the build that produced it."
567 )
568 return dict(policy)
569
570
571def resolve_workspace_path(anchor_file: str, candidate: str, *, allow_external: bool = False) -> str:
572 """!
573 @brief Resolve a user path against its workspace and reject implicit escapes.
574 @param[in] anchor_file Config file whose workspace owns the reference.
575 @param[in] candidate Workspace-relative path text.
576 @param[in] allow_external Whether an explicit import/reference operation permits an absolute path.
577 @return Absolute resolved path.
578 """
579 if not isinstance(candidate, str) or not candidate.strip():
580 raise ValueError("Referenced path must be a non-empty string.")
581 text = os.path.expanduser(candidate.strip())
582 workspace_root = find_workspace_root(anchor_file)
583 if not workspace_root:
584 return os.path.abspath(text if os.path.isabs(text) else os.path.join(os.path.dirname(os.path.abspath(anchor_file)), text))
585 if os.path.isabs(text):
586 if allow_external:
587 return os.path.abspath(text)
588 raise ValueError(
589 f"{anchor_file}: absolute path {candidate!r} is not allowed in workspace configuration; "
590 "import it with 'picurv inputs import' or use an explicit reference-mode import."
591 )
592 resolved = os.path.abspath(os.path.join(workspace_root, text))
593 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
594 raise ValueError(
595 f"{anchor_file}: path {candidate!r} escapes the workspace; parent traversal is not allowed."
596 )
597 if resolved.endswith(".reference.yml") and os.path.isfile(resolved):
598 pointer = read_yaml_file(resolved)
599 external = pointer.get("picurv_external_reference") if isinstance(pointer, dict) else None
600 if not isinstance(external, str) or not os.path.isabs(external):
601 raise ValueError(f"Invalid external-reference descriptor: {resolved}")
602 if not os.path.isfile(external):
603 raise ValueError(f"Registered external input is unavailable: {external}")
604 return external
605 return resolved
606
607
608def ensure_workspace_layout(workspace_root: str) -> None:
609 """!
610 @brief Materialize the uniform, cheap directory skeleton for one workspace.
611 @param[in] workspace_root Workspace root to initialize.
612 """
613 for relative in WORKSPACE_DIRECTORY_LAYOUT:
614 os.makedirs(os.path.join(workspace_root, *relative.split("/")), exist_ok=True)
615
616
617def initialize_workspace_root(workspace_root: str, template_name: str) -> str:
618 """!
619 @brief Create the workspace skeleton and its identity file at one root.
620 @param[in] workspace_root Workspace root to initialize.
621 @param[in] template_name Example template the workspace was created from.
622 @return Path to the written workspace configuration file.
623 """
624 workspace_root = os.path.abspath(workspace_root)
625 ensure_workspace_layout(workspace_root)
626 config_path = os.path.join(workspace_root, WORKSPACE_CONFIG_FILENAME)
627 write_yaml_file(config_path, {
628 "schema_version": WORKSPACE_SCHEMA_VERSION,
629 "workspace": {
630 "id": os.path.basename(workspace_root),
631 "template": template_name,
632 "created_at": datetime.now().astimezone().isoformat(),
633 },
634 "software": {},
635 "paths": {
636 "config": "config",
637 "inputs": "inputs",
638 "assets": "assets",
639 "runs": "runs",
640 "studies": "studies",
641 },
642 })
643 return config_path
644
645
646def ensure_run_layout(run_dir: str) -> None:
647 """!
648 @brief Materialize the uniform, cheap directory skeleton for one run.
649 @param[in] run_dir Run root to initialize.
650 """
651 for relative in RUN_DIRECTORY_LAYOUT:
652 os.makedirs(os.path.join(run_dir, *relative.split("/")), exist_ok=True)
653
654
655#: Top-level directory names a run owns. Anything else beside them is a peer PICurv
656#: does not route, cannot classify for storage, and will not prune.
657RUN_DIRECTORY_ROOTS = frozenset(
658 relative.split("/")[0] for relative in RUN_DIRECTORY_LAYOUT
659)
660
661#: Files a run legitimately carries at its own root. Storage's own markers are added
662#: at validation time, so this stays the list of what the conductor writes.
663RUN_ROOT_ALLOWED_FILES = frozenset({"manifest.json"})
664
665
666def validate_run_directory_structure(run_dir: str) -> tuple:
667 """!
668 @brief Refuse a run whose root has grown a directory the layout does not define.
669
670 @details The run topology is a contract: `manifest.json` publishes it, the solver's
671 reserved-directory guard defends it, and storage classifies against it. A
672 directory beside `output/` breaks all three at once - it is routed by
673 nothing, classified as `unclassified`, and therefore archived forever and
674 pruned never. Catching it when the run is staged or resumed is the only
675 point where the answer is still "move it", rather than "it is already in
676 every archive of this run".
677
678 Unexpected *files* are reported and allowed. A stray note at a run root
679 costs nothing and refusing to resume a long campaign over one would be a
680 worse failure than the one being prevented.
681 @param[in] run_dir Run root to check.
682 @return Tuple of (errors, warnings) as human-readable message lists.
683 """
684 root = os.path.abspath(run_dir)
685 if not os.path.isdir(root):
686 return [], []
687 allowed_files = set(RUN_ROOT_ALLOWED_FILES) | {
688 STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME,
689 }
690 errors, warnings = [], []
691 for name in sorted(os.listdir(root)):
692 path = os.path.join(root, name)
693 if os.path.isdir(path) and not os.path.islink(path):
694 if name not in RUN_DIRECTORY_ROOTS:
695 errors.append(
696 f"{run_dir}: '{name}/' is not part of the run layout. Scientific "
697 f"output belongs under {CANONICAL_RUN_PATHS['checkpoints']}, "
698 f"{CANONICAL_RUN_PATHS['analysis']}, or "
699 f"{CANONICAL_RUN_PATHS['visualization']}; move or remove '{name}/' "
700 f"before running. Run-owned roots are: "
701 + ", ".join(sorted(RUN_DIRECTORY_ROOTS)) + "."
702 )
703 elif name not in allowed_files:
704 warnings.append(
705 f"{run_dir}: unexpected file '{name}' at the run root. It is archived "
706 "as unclassified and never pruned."
707 )
708 return errors, warnings
709
710
711def enforce_run_directory_structure(run_dir: str) -> None:
712 """!
713 @brief Apply `validate_run_directory_structure()` as a refusal at run time.
714 @param[in] run_dir Run root to check.
715 @return None. Exits non-zero when the run root carries a directory outside the layout.
716 """
717 errors, warnings = validate_run_directory_structure(run_dir)
718 for message in warnings:
719 print(f"[WARN] {message}", file=sys.stderr)
720 if not errors:
721 return
722 for message in errors:
724 ERROR_CODE_CFG_INCONSISTENT_COMBO,
725 key="run_layout",
726 file_path=os.path.abspath(run_dir),
727 message=message,
728 hint="Move the directory under output/ or out of the run, then retry.",
729 )
730 sys.exit(1)
731
732
733def case_run_label(case_cfg: dict, case_path: str) -> str:
734 """!
735 @brief Resolve the stable human-facing portion of a generated run identifier.
736 @param[in] case_cfg Parsed case configuration.
737 @param[in] case_path Source case YAML path.
738 @return Filesystem-safe case title, name, or source stem.
739 """
740 metadata = case_cfg.get("metadata") or {}
741 candidate = (
742 case_cfg.get("title")
743 or (metadata.get("title") if isinstance(metadata, dict) else None)
744 or (metadata.get("name") if isinstance(metadata, dict) else None)
745 or Path(case_path).stem
746 )
747 label = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(candidate).strip()).strip("-.")
748 return label or "case"
749
750
751def allocate_generated_run_id(runs_root: str, case_cfg: dict, case_path: str) -> str:
752 """!
753 @brief Build the generated run identity, disambiguating a same-second collision.
754
755 @details The identity is `<run label>_<timestamp>` at one-second resolution, so two
756 runs of the same case launched back to back - a script, or a smoke
757 sequence - would otherwise name the same directory. Creating a run never
758 writes into an existing one, so the identity is advanced instead of the
759 launch being refused for a clock artifact.
760 @param[in] runs_root Directory generated runs are created beneath.
761 @param[in] case_cfg Parsed case configuration supplying the run label.
762 @param[in] case_path Case path, for the label's fallback.
763 @return Run identity that does not currently exist under `runs_root`.
764 """
765 base = f"{case_run_label(case_cfg, case_path)}_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
766 run_id, suffix = base, 1
767 while os.path.exists(os.path.join(runs_root, run_id)):
768 suffix += 1
769 run_id = f"{base}-{suffix}"
770 return run_id
771
772
773def discard_unused_run_directory(run_dir: str, *, created: bool) -> bool:
774 """!
775 @brief Remove a generated run directory that never received any content.
776
777 @details Only a directory this invocation created, and which still holds no files,
778 is removed: an existing run, or one that already produced output, is never
779 touched by a staging failure.
780 @param[in] run_dir Run directory that was about to be staged.
781 @param[in] created Whether this invocation created the directory.
782 @return True when the directory was removed.
783 """
784 if not created or not os.path.isdir(run_dir):
785 return False
786 for _root, _dirs, files in os.walk(run_dir):
787 if files:
788 return False
789 shutil.rmtree(run_dir, ignore_errors=True)
790 return True
791
792
793def _file_sha256(path: str):
794 """!
795 @brief Content digest of one file, or None when it cannot be read.
796 @param[in] path File to digest.
797 @return Hex digest, or None.
798 """
799 digest = hashlib.sha256()
800 try:
801 with open(path, "rb") as stream:
802 for block in iter(lambda: stream.read(1024 * 1024), b""):
803 digest.update(block)
804 except OSError:
805 return None
806 return digest.hexdigest()
807
808
810 """!
811 @brief Capture the exact software identity a run is about to execute with.
812
813 @details The release and commit say which source is checked out; they do not say
814 which bytes ran. Hashing the executables and the generators pins that, so
815 a queued job rebuilt out from under it is detectable afterwards rather
816 than merely suspected.
817 @return Software lock mapping.
818 """
819 lock = {
820 "schema_version": 1,
821 "picurv_version": PICURV_RELEASE_VERSION,
822 "build_id": PICURV_BUILD.get("build_id"),
823 "git_commit": PICURV_BUILD.get("git_commit"),
824 "git_dirty": PICURV_BUILD.get("dirty"),
825 "captured_at": datetime.now().astimezone().isoformat(),
826 "executables": {},
827 "generators": {},
828 "environment": _toolchain_identity(),
829 }
830 for name in ("simulator", "postprocessor"):
831 path = resolve_runtime_executable(name)
832 identity = read_binary_build_identity(path)
833 lock["executables"][name] = {
834 "path": path,
835 "sha256": _file_sha256(path),
836 "build_id": identity.get("build_id"),
837 "matches_source": identity.get("matches_source"),
838 }
839 conductor = os.path.join(PACKAGE_PROJECT_ROOT, "picurv_cli", "core.py")
840 lock["python_conductor_sha256"] = _file_sha256(conductor)
841 if os.path.isdir(GENERATORS_PATH):
842 for entry in sorted(os.listdir(GENERATORS_PATH)):
843 candidate = os.path.join(GENERATORS_PATH, entry)
844 if os.path.isfile(candidate):
845 lock["generators"][entry] = _file_sha256(candidate)
846 return lock
847
848
850 """!
851 @brief Best-effort record of the PETSc, MPI, and compiler the binaries were built on.
852 @return Mapping of what could be determined; absent keys mean it could not be read.
853 """
854 identity = {}
855 petsc_dir = os.environ.get("PETSC_DIR")
856 if petsc_dir:
857 identity["petsc_dir"] = petsc_dir
858 version_header = os.path.join(petsc_dir, "include", "petscversion.h")
859 try:
860 with open(version_header, "r", encoding="utf-8", errors="replace") as stream:
861 numbers = {}
862 for line in stream:
863 match = re.match(
864 r"#define\s+PETSC_VERSION_(MAJOR|MINOR|SUBMINOR)\s+(\d+)", line
865 )
866 if match:
867 numbers[match.group(1)] = match.group(2)
868 if len(numbers) == 3:
869 identity["petsc_version"] = (
870 f"{numbers['MAJOR']}.{numbers['MINOR']}.{numbers['SUBMINOR']}"
871 )
872 except OSError:
873 pass
874 if os.environ.get("PETSC_ARCH"):
875 identity["petsc_arch"] = os.environ["PETSC_ARCH"]
876 for tool, key in (("mpiexec", "mpi"), ("mpicc", "compiler")):
877 executable = shutil.which(tool)
878 if not executable:
879 continue
880 try:
881 result = subprocess.run(
882 [executable, "--version"], text=True, capture_output=True, timeout=20, check=False
883 )
884 except (OSError, subprocess.SubprocessError):
885 continue
886 first = (result.stdout or result.stderr or "").strip().splitlines()
887 if first:
888 identity[key] = first[0][:200]
889 return identity
890
891
892def write_software_lock(run_dir: str) -> str:
893 """!
894 @brief Write the run's software lock beside its asset lock.
895 @param[in] run_dir Run directory receiving the lock.
896 @return Path to the written lock.
897 """
898 path = os.path.join(run_dir, CANONICAL_RUN_PATHS["inputs"], "software.lock.json")
899 os.makedirs(os.path.dirname(path), exist_ok=True)
901 return path
902
903
904def workspace_artifact_root(workspace_root: str, kind: str) -> str:
905 """!
906 @brief Return the canonical workspace-owned root for runs or studies.
907 @param[in] workspace_root Initialized workspace, or None for standalone mode.
908 @param[in] kind Either runs or studies.
909 @return Absolute artifact root.
910 """
911 if kind not in _WORKSPACE_ARTIFACT_ROOT_VALUES:
912 raise ValueError(f"Unsupported workspace artifact kind: {kind}")
913 if workspace_root:
914 return os.path.join(os.path.abspath(workspace_root), kind)
915 return os.path.abspath(kind)
916
917
918def _relative_to_workspace(path: str, workspace_root: str):
919 """!
920 @brief Return a portable workspace-relative path when possible.
921 @param[in] path Path to normalize.
922 @param[in] workspace_root Optional initialized workspace root.
923 @return Workspace-relative POSIX path, absolute path, or None.
924 """
925 if not path:
926 return None
927 absolute = os.path.abspath(path)
928 if workspace_root and os.path.commonpath([absolute, workspace_root]) == os.path.abspath(workspace_root):
929 return os.path.relpath(absolute, workspace_root).replace(os.sep, "/")
930 return absolute
931
932
933def snapshot_run_configuration(run_dir: str, source_paths: dict,
934 continuation: bool = False) -> dict:
935 """!
936 @brief Snapshot editable YAML inputs without erasing prior continuation state.
937 @param[in] run_dir Owning run directory.
938 @param[in] source_paths Role-to-source-path mapping.
939 @param[in] continuation Whether this is a continuation configuration revision.
940 @return Active snapshot metadata.
941 """
942 config_root = os.path.join(run_dir, "config")
943 os.makedirs(config_root, exist_ok=True)
944 revision = None
945 destination_root = config_root
946 if continuation:
947 revision = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
948 destination_root = os.path.join(config_root, "history", revision)
949 os.makedirs(destination_root, exist_ok=False)
950 snapshots = {}
951 for role, source in source_paths.items():
952 if not source:
953 continue
954 source = os.path.abspath(source)
955 suffix = os.path.splitext(source)[1] or ".yml"
956 name = f"{role}{suffix}" if role != "cluster" else "cluster.yml"
957 destination = os.path.join(destination_root, name)
958 shutil.copy2(source, destination)
959 snapshots[role] = os.path.relpath(destination, run_dir).replace(os.sep, "/")
960 active = {
961 "schema_version": 1,
962 "revision": revision or "initial",
963 "updated_at": datetime.now().astimezone().isoformat(),
964 "files": snapshots,
965 }
966 write_json_file(os.path.join(config_root, "active.json"), active)
967 return active
968
969
970def load_active_run_configuration(run_dir: str) -> dict:
971 """!
972 @brief Load the active immutable configuration revision for a run.
973 @param[in] run_dir Run directory.
974 @return Role-to-absolute-path mapping.
975 """
976 config_root = os.path.join(os.path.abspath(run_dir), "config")
977 active = _read_json_if_exists(os.path.join(config_root, "active.json"))
978 if isinstance(active, dict) and isinstance(active.get("files"), dict):
979 return {
980 role: os.path.join(run_dir, *relative.split("/"))
981 for role, relative in active["files"].items()
982 if isinstance(relative, str)
983 }
984 return {
985 role: os.path.join(config_root, f"{role}.yml")
986 for role in ("case", "solver", "monitor", "cluster")
987 if os.path.isfile(os.path.join(config_root, f"{role}.yml"))
988 }
989
990
991def archive_active_generated_configuration(run_dir: str, paths: list) -> dict:
992 """!
993 @brief Preserve generated control sidecars beside a continuation's YAML revision.
994 @param[in] run_dir Owning run.
995 @param[in] paths Generated control and sidecar paths.
996 @return Updated active configuration record.
997 """
998 config_root = os.path.join(os.path.abspath(run_dir), "config")
999 active_path = os.path.join(config_root, "active.json")
1000 active = _read_json_if_exists(active_path) or {"schema_version": 1, "revision": "initial", "files": {}}
1001 revision = active.get("revision", "initial")
1002 target_root = config_root if revision == "initial" else os.path.join(config_root, "history", revision)
1003 os.makedirs(target_root, exist_ok=True)
1004 files = active.setdefault("files", {})
1005 for source in paths:
1006 if not source or not os.path.isfile(source):
1007 continue
1008 source = os.path.abspath(source)
1009 destination = os.path.join(target_root, os.path.basename(source))
1010 if source != destination:
1011 shutil.copy2(source, destination)
1012 relative = os.path.relpath(destination, run_dir).replace(os.sep, "/")
1013 if destination.endswith(".control"):
1014 files["control"] = relative
1015 else:
1016 files.setdefault("generated", []).append(relative)
1017 active["updated_at"] = datetime.now().astimezone().isoformat()
1018 write_json_file(active_path, active)
1019 return active
1020
1021
1022#: Storage states in which a run's heavy payload is no longer wholly local. A component
1023#: absent under one of these was archived, not skipped.
1024STORAGE_OFFLOADED_STATES = frozenset({"COLD", "PARTIAL"})
1025
1026#: Lifecycle states a run component can be in, as reported by the run manifest.
1027RUN_COMPONENT_STATES = (
1028 "not_requested", "planned", "running", "partial", "complete", "failed", "offloaded",
1029)
1030
1031#: Run components, their canonical home, and the retention class storage applies.
1032RUN_COMPONENT_LAYOUT = (
1033 ("configuration", "config", "essential"),
1034 ("inputs", "inputs", "essential"),
1035 ("checkpoints", "output/checkpoints", "policy"),
1036 ("analysis", "output/analysis", "derived"),
1037 ("field_statistics", "output/analysis/statistics", "derived"),
1038 ("spectra", "output/analysis/spectra", "derived"),
1039 ("visualization", "output/visualization", "derived"),
1040 ("logs", "logs", "essential"),
1041 ("scheduler", "scheduler", "essential"),
1042)
1043
1044
1045def _run_component_states(run_dir: str, stages_requested: dict) -> dict:
1046 """!
1047 @brief Report each run component's home, retention class, and lifecycle state.
1048
1049 @details The skeleton is created whole, so an empty directory says nothing about
1050 whether its contents were requested. This is where that question is
1051 answered, and it is answered for every component whether or not it ran.
1052 @param[in] run_dir Run directory being described.
1053 @param[in] stages_requested Mapping of requested stage names to booleans.
1054 @return Component name to `{path, retention, state}` mapping.
1055 """
1056 marker = storage_state_summary(run_dir) if os.path.isdir(run_dir) else {"state": "LOCAL"}
1057 offloaded = marker.get("state") in STORAGE_OFFLOADED_STATES
1058 solve_requested = bool(stages_requested.get("solve"))
1059 post_requested = bool(stages_requested.get("post_process"))
1060 requested = {
1061 "configuration": True,
1062 "inputs": True,
1063 "logs": True,
1064 "scheduler": True,
1065 "checkpoints": solve_requested,
1066 "analysis": post_requested,
1067 "field_statistics": post_requested,
1068 "spectra": post_requested,
1069 "visualization": post_requested,
1070 }
1071 components = {}
1072 for name, relative, retention in RUN_COMPONENT_LAYOUT:
1073 path = os.path.join(run_dir, *relative.split("/"))
1074 populated = False
1075 if os.path.isdir(path):
1076 populated = any(files for _root, _dirs, files in os.walk(path))
1077 if populated:
1078 state = "offloaded" if offloaded and retention != "essential" else "complete"
1079 elif not requested.get(name, False):
1080 state = "not_requested"
1081 elif offloaded:
1082 state = "offloaded"
1083 else:
1084 state = "planned"
1085 components[name] = {"path": relative, "retention": retention, "state": state}
1086 return components
1087
1088
1089def build_run_lineage(parent_run_dir: str, checkpoint_step: int, *, workspace_root=None,
1090 statistics_state=None, requested_source=None) -> dict:
1091 """!
1092 @brief Record which run and which checkpoint a branched run was started from.
1093
1094 @details Without this a branch is indistinguishable from a fresh run: the copied
1095 bundle under `inputs/restart/` carries the parent's geometry and software
1096 identity but never says which run produced it, so the trajectory a result
1097 belongs to cannot be reconstructed after the fact.
1098
1099 The parent's own manifest is asked for its identity rather than its
1100 directory name, so a parent that was renamed or restored under another
1101 name is still named correctly here.
1102 @param[in] parent_run_dir Absolute path to the run being branched from.
1103 @param[in] checkpoint_step Checkpoint step the branch starts from.
1104 @param[in] workspace_root Optional workspace root, to record a portable path.
1105 @param[in] statistics_state Resolved field-statistics decision for the branch.
1106 @param[in] requested_source What the user asked for, e.g. "latest" or a path.
1107 @return JSON-serializable lineage record.
1108 """
1109 parent_identity = read_artifact_identity(parent_run_dir)
1110 return {
1111 "relationship": "branch",
1112 "parent_run_id": parent_identity["run_id"],
1113 "parent_study_id": parent_identity["study_id"] if parent_identity["case_id"] else None,
1114 "parent_case_id": parent_identity["case_id"],
1115 "parent_identity_source": parent_identity["identity_source"],
1116 "parent_path": _relative_to_workspace(parent_run_dir, workspace_root),
1117 "checkpoint_step": int(checkpoint_step),
1118 "statistics_state": statistics_state,
1119 "requested_source": requested_source,
1120 "recorded_at": datetime.now().astimezone().isoformat(),
1121 }
1122
1123
1124def build_run_manifest(run_dir: str, run_id: str, *, workspace_root=None,
1125 launch_mode: str = "local", num_procs: int = 1,
1126 post_num_procs: int = 1, stages_requested=None,
1127 stages_completed=None, inputs=None, asset_lock=None,
1128 submission=None, lineage=None, artifact_type: str = "run",
1129 study_id=None, case_id=None) -> dict:
1130 """!
1131 @brief Build the authoritative run identity, topology, and lifecycle manifest.
1132 @param[in] run_dir Owning run directory.
1133 @param[in] run_id Stable run identity.
1134 @param[in] workspace_root Optional initialized workspace root.
1135 @param[in] launch_mode Local or scheduler-backed launch mode.
1136 @param[in] num_procs Effective solver process count.
1137 @param[in] post_num_procs Effective post-process count.
1138 @param[in] stages_requested Requested stage mapping.
1139 @param[in] stages_completed Completed or submitted stage names.
1140 @param[in] inputs Active configuration identities.
1141 @param[in] asset_lock Resolved run asset lock.
1142 @param[in] submission Scheduler submission metadata.
1143 @param[in] lineage Where this run's initial state came from; see `build_run_lineage()`.
1144 @param[in] artifact_type "run" for a standalone run, "study-case" for a study member.
1145 @param[in] study_id Owning study identity, for a study member.
1146 @param[in] case_id Member identity within its study.
1147 @return JSON-serializable run manifest.
1148 """
1149 existing = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
1150 created_at = existing.get("created_at") or datetime.now().astimezone().isoformat()
1151 payload = {
1152 "schema_version": RUN_MANIFEST_SCHEMA_VERSION,
1153 "artifact_type": artifact_type,
1154 "run_id": run_id,
1155 "study_id": study_id,
1156 "case_id": case_id,
1157 "created_at": created_at,
1158 "updated_at": datetime.now().astimezone().isoformat(),
1159 "workspace": (
1160 (load_workspace_config(workspace_root).get("workspace") or {})
1161 if workspace_root else None
1162 ),
1163 "software": dict(PICURV_BUILD),
1164 # The conductor's identity says which source staged the run; the binaries'
1165 # says which build produced its checkpoints. They can differ - an edited C
1166 # tree that was never rebuilt - so provenance records both.
1167 "binaries": runtime_build_identities(),
1168 "launch_mode": launch_mode,
1169 "num_procs": num_procs,
1170 "solver_num_procs": num_procs,
1171 "post_num_procs": post_num_procs,
1172 "stages_requested": stages_requested or {},
1173 "stages_completed_or_submitted": stages_completed or [],
1174 "inputs": inputs or {},
1175 "paths": dict(CANONICAL_RUN_PATHS),
1176 # Fixed schema, every component present whatever happened, so a reader never
1177 # has to distinguish "absent because it was not asked for" from "absent because
1178 # it failed" by looking for empty directories.
1179 "components": _run_component_states(run_dir, stages_requested or {}),
1180 "assets": (asset_lock or {}).get("assets", {}),
1181 "runtime_providers": (asset_lock or {}).get("runtime_providers", {}),
1182 # Always present, so a reader distinguishes "this run began from nothing" from
1183 # "this manifest predates lineage" by the value rather than by the key's absence.
1184 # A re-staged continuation keeps the lineage recorded when the run was created:
1185 # what a run branched from does not change because it was resumed.
1186 "lineage": lineage or existing.get("lineage") or {"relationship": "root"},
1187 "submission": submission or {},
1188 }
1189 return payload
1190
1191
1192def _checkpoint_bundle_path(source_dir: str, step: int) -> str:
1193 """!
1194 @brief Resolve a run/output root or an exact checkpoint bundle.
1195 @param[in] source_dir Run/output root or exact bundle path.
1196 @param[in] step Checkpoint step to resolve.
1197 @return Absolute path to the checkpoint bundle.
1198 """
1199 source_dir = os.path.abspath(source_dir)
1200 if os.path.isfile(os.path.join(source_dir, "checkpoint.meta")):
1201 return source_dir
1202 return os.path.join(source_dir, "checkpoints", f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}")
1203
1204
1205def _read_checkpoint_options(metadata_path: str) -> dict:
1206 """!
1207 @brief Parse the deliberately small PETSc-options checkpoint manifest.
1208 @param[in] metadata_path Path to checkpoint.meta.
1209 @return Mapping of option names to scalar text values.
1210 """
1211 options = {}
1212 with open(metadata_path, "r", encoding="utf-8") as stream:
1213 for line_number, raw_line in enumerate(stream, start=1):
1214 tokens = shlex.split(raw_line, comments=True, posix=True)
1215 if not tokens:
1216 continue
1217 if len(tokens) != 2 or not tokens[0].startswith("-"):
1218 raise ValueError(
1219 f"Invalid checkpoint metadata line {line_number} in {metadata_path}."
1220 )
1221 key = tokens[0][1:]
1222 if key in options:
1223 raise ValueError(f"Duplicate checkpoint metadata key '-{key}' in {metadata_path}.")
1224 options[key] = tokens[1]
1225 return options
1226
1227
1228def validate_committed_checkpoint(source_dir: str, step: int, require_particles: bool = False) -> dict:
1229 """!
1230 @brief Validate one committed bundle using the same manifest contract as C.
1231 @param[in] source_dir Run/output root or exact bundle path.
1232 @param[in] step Expected checkpoint step.
1233 @param[in] require_particles Whether particle restart payloads are required.
1234 @return Validated bundle path, metadata, and payload inventory.
1235 """
1236 bundle = _checkpoint_bundle_path(source_dir, step)
1237 metadata_path = os.path.join(bundle, "checkpoint.meta")
1238 commit_path = os.path.join(bundle, "COMMITTED")
1239 if not os.path.isfile(metadata_path) or not os.path.isfile(commit_path):
1240 raise ValueError(f"No committed checkpoint for step {step}: {bundle}")
1241
1242 with open(commit_path, "r", encoding="ascii") as stream:
1243 expected_digest = stream.read().strip()
1244 if not re.fullmatch(r"[0-9a-fA-F]{64}", expected_digest):
1245 raise ValueError(f"Invalid checkpoint commit marker: {commit_path}")
1246 with open(metadata_path, "rb") as stream:
1247 actual_digest = hashlib.sha256(stream.read()).hexdigest()
1248 if actual_digest.lower() != expected_digest.lower():
1249 raise ValueError(f"Checkpoint metadata hash mismatch: {bundle}")
1250
1251 options = _read_checkpoint_options(metadata_path)
1252 try:
1253 saved_version = int(options.get("checkpoint_version", "-1"))
1254 saved_step = int(options.get("checkpoint_step", "-1"))
1255 payload_count = int(options.get("checkpoint_payload_count", "-1"))
1256 particle_count = int(options.get("checkpoint_particle_count", "-1"))
1257 block_count = int(options.get("checkpoint_block_count", "-1"))
1258 float(options["checkpoint_time"])
1259 except (KeyError, TypeError, ValueError) as exc:
1260 raise ValueError(f"Checkpoint metadata is incomplete or malformed: {metadata_path}") from exc
1261 if options.get("checkpoint_format") != CHECKPOINT_FORMAT or saved_version != CHECKPOINT_VERSION:
1262 raise ValueError(f"Unsupported checkpoint format/version: {metadata_path}")
1263 if saved_step != step:
1264 raise ValueError(f"Checkpoint records step {saved_step}, expected {step}: {bundle}")
1265 if payload_count <= 0 or particle_count < 0 or block_count <= 0:
1266 raise ValueError(f"Checkpoint inventory counts are invalid: {metadata_path}")
1267
1268 payloads = []
1269 for index in range(payload_count):
1270 prefix = f"checkpoint_payload_{index}_"
1271 relative_path = options.get(prefix + "path")
1272 try:
1273 expected_bytes = int(options[prefix + "bytes"])
1274 except (KeyError, TypeError, ValueError) as exc:
1275 raise ValueError(f"Checkpoint payload {index} has invalid metadata: {metadata_path}") from exc
1276 if not relative_path or os.path.isabs(relative_path) or ".." in relative_path.split("/"):
1277 raise ValueError(f"Checkpoint payload {index} has an unsafe path: {relative_path!r}")
1278 payload_path = os.path.join(bundle, *relative_path.split("/"))
1279 if not os.path.isfile(payload_path) or os.path.getsize(payload_path) != expected_bytes:
1280 raise ValueError(f"Checkpoint payload is missing or truncated: {payload_path}")
1281 payloads.append({
1282 "path": relative_path,
1283 "kind": options.get(prefix + "kind"),
1284 "field": options.get(prefix + "field"),
1285 "block": options.get(prefix + "block"),
1286 })
1287
1288 for block in range(block_count):
1289 fields = {
1290 item["field"] for item in payloads
1291 if item["kind"] == "eulerian" and item["block"] == str(block)
1292 }
1293 missing = CHECKPOINT_REQUIRED_EULERIAN_FIELDS - fields
1294 if missing:
1295 raise ValueError(
1296 f"Checkpoint block {block} is missing required Eulerian field(s): "
1297 f"{', '.join(sorted(missing))}."
1298 )
1299 particle_payloads = [item for item in payloads if item["kind"] == "particle"]
1300 has_particles = options.get("checkpoint_particles", "false").lower() == "true"
1301 if (not has_particles and particle_count != 0) or (has_particles and not particle_payloads):
1302 raise ValueError(f"Checkpoint particle inventory is inconsistent: {metadata_path}")
1303 if require_particles and not has_particles:
1304 raise ValueError(f"Checkpoint step {step} does not contain particle state: {bundle}")
1305
1306 return {
1307 "bundle": bundle,
1308 "metadata": options,
1309 "payloads": payloads,
1310 "has_particles": has_particles,
1311 "particle_count": particle_count,
1312 }
1313
1314
1315def _scan_committed_checkpoint_steps(source_dir: str, require_particles: bool = False) -> "set[int]":
1316 """!
1317 @brief Return only fully validated, committed checkpoint steps.
1318 @param[in] source_dir Run/output root containing checkpoints.
1319 @param[in] require_particles Whether particle restart payloads are required.
1320 @return Set of valid committed step numbers.
1321 """
1322 checkpoints_dir = os.path.join(os.path.abspath(source_dir), "checkpoints")
1323 if not os.path.isdir(checkpoints_dir):
1324 return set()
1325 pattern = re.compile(rf"^step_(\d{{{CHECKPOINT_STEP_WIDTH}}})$")
1326 steps = set()
1327 for name in os.listdir(checkpoints_dir):
1328 match = pattern.fullmatch(name)
1329 if not match:
1330 continue
1331 step = int(match.group(1))
1332 try:
1333 validate_committed_checkpoint(source_dir, step, require_particles=require_particles)
1334 except ValueError:
1335 continue
1336 steps.add(step)
1337 return steps
1338
1339
1340def parse_slurm_time_limit_to_seconds(time_text: str) -> int:
1341 """!
1342 @brief Parse a Slurm time-limit string into total seconds.
1343 @param[in] time_text Argument passed to `parse_slurm_time_limit_to_seconds()`.
1344 @return Value returned by `parse_slurm_time_limit_to_seconds()`.
1345 """
1346 text = str(time_text).strip()
1347 if not text:
1348 raise ValueError("time limit cannot be empty")
1349
1350 days = 0
1351 clock_text = text
1352 if "-" in text:
1353 day_text, clock_text = text.split("-", 1)
1354 if not day_text.isdigit():
1355 raise ValueError(f"invalid day field '{day_text}'")
1356 days = int(day_text)
1357 if not clock_text:
1358 raise ValueError("missing time portion after day field")
1359
1360 parts = clock_text.split(":")
1361 if len(parts) > 3:
1362 raise ValueError(f"unsupported time format '{time_text}'")
1363 if any(part == "" for part in parts):
1364 raise ValueError(f"malformed time field '{time_text}'")
1365 if any(not part.isdigit() for part in parts):
1366 raise ValueError(f"non-numeric time field '{time_text}'")
1367
1368 nums = [int(part) for part in parts]
1369 if days > 0:
1370 if len(nums) == 1:
1371 hours, minutes, seconds = nums[0], 0, 0
1372 elif len(nums) == 2:
1373 hours, minutes = nums
1374 seconds = 0
1375 else:
1376 hours, minutes, seconds = nums
1377 else:
1378 if len(nums) == 1:
1379 hours, minutes, seconds = 0, nums[0], 0
1380 elif len(nums) == 2:
1381 hours = 0
1382 minutes, seconds = nums
1383 else:
1384 hours, minutes, seconds = nums
1385
1386 if minutes >= 60 or seconds >= 60:
1387 raise ValueError(f"minutes and seconds must be < 60 in '{time_text}'")
1388 if days == 0 and len(nums) == 3 and hours < 0:
1389 raise ValueError(f"hours must be non-negative in '{time_text}'")
1390
1391 total_seconds = (((days * 24) + hours) * 60 + minutes) * 60 + seconds
1392 if total_seconds <= 0:
1393 raise ValueError("time limit must be positive")
1394 return total_seconds
1395
1396
1397def resolve_walltime_guard_policy(cluster_cfg: "dict | None") -> "dict | None":
1398 """!
1399 @brief Resolve the effective Slurm walltime-guard policy for generated solver jobs.
1400 @param[in] cluster_cfg Argument passed to `resolve_walltime_guard_policy()`.
1401 @return Value returned by `resolve_walltime_guard_policy()`.
1402 """
1403 if not isinstance(cluster_cfg, dict):
1404 return None
1405
1406 scheduler = cluster_cfg.get("scheduler", {}) or {}
1407 if str(scheduler.get("type", "slurm")).lower() != "slurm":
1408 return None
1409
1410 execution = cluster_cfg.get("execution", {}) or {}
1411 guard_cfg = execution.get("walltime_guard")
1412 if guard_cfg is None:
1413 guard_cfg = {}
1414 elif not isinstance(guard_cfg, dict):
1415 raise ValueError("execution.walltime_guard must be a mapping when provided")
1416
1417 policy = copy.deepcopy(DEFAULT_WALLTIME_GUARD_POLICY)
1418 policy.update(guard_cfg)
1419 policy["enabled"] = bool(policy["enabled"])
1420 policy["warmup_steps"] = int(policy["warmup_steps"])
1421 policy["multiplier"] = float(policy["multiplier"])
1422 policy["min_seconds"] = float(policy["min_seconds"])
1423 policy["estimator_alpha"] = float(policy["estimator_alpha"])
1424 return policy
1425
1426
1427def build_walltime_guard_exports(cluster_cfg: "dict | None") -> dict:
1428 """!
1429 @brief Build shell-evaluated environment exports for the runtime walltime guard.
1430 @param[in] cluster_cfg Argument passed to `build_walltime_guard_exports()`.
1431 @return Value returned by `build_walltime_guard_exports()`.
1432 """
1433 policy = resolve_walltime_guard_policy(cluster_cfg)
1434 if not policy or not policy.get("enabled", False):
1435 return {}
1436 walltime_limit_seconds = parse_slurm_time_limit_to_seconds(cluster_cfg.get("resources", {}).get("time", ""))
1437 return {
1438 WALLTIME_GUARD_ENV_JOB_START_EPOCH: "$(date +%s)",
1439 WALLTIME_GUARD_ENV_LIMIT_SECONDS: str(walltime_limit_seconds),
1440 }
1441
1442def resolve_runtime_executable(executable_name: str) -> str:
1443 """!
1444 @brief Resolve solver/post executable path, preferring local sibling binaries.
1445 @param[in] executable_name Argument passed to `resolve_runtime_executable()`.
1446 @return Value returned by `resolve_runtime_executable()`.
1447 """
1448 local_candidate = os.path.join(INVOKED_SCRIPT_DIR, executable_name)
1449 if os.path.isfile(local_candidate):
1450 return os.path.abspath(local_candidate)
1451 return os.path.join(DEFAULT_BIN_DIR, executable_name)
1452
1453
1454#: Identity a native executable prints for `--version`: `<name> <release>+g<commit>[.dirty]`.
1455_BINARY_VERSION_PATTERN = re.compile(
1456 r"^(?P<name>\S+)\s+(?P<release>[^+\s]+)\+g(?P<commit>[0-9a-f]+)(?P<dirty>\.dirty)?\s*$"
1457)
1458
1459
1460def read_binary_build_identity(executable_path: str) -> dict:
1461 """!
1462 @brief Read the build identity a native executable was compiled with.
1463
1464 @details The Makefile stamps the release, commit, and dirty state into the
1465 binaries, and they are written into every checkpoint manifest. Reading it
1466 back is what makes the run manifest's provenance a statement about the
1467 binary that ran rather than about whatever source happens to be checked
1468 out when the conductor is invoked.
1469 @param[in] executable_path Path to `simulator` or `postprocessor`.
1470 @return Identity mapping, or an `available: False` mapping when it cannot be read.
1471 """
1472 if not os.path.isfile(executable_path) or not os.access(executable_path, os.X_OK):
1473 return {"available": False, "reason": "not built", "path": executable_path}
1474 try:
1475 result = subprocess.run(
1476 [executable_path, "--version"], text=True, capture_output=True, timeout=30,
1477 )
1478 except (OSError, subprocess.SubprocessError) as exc:
1479 return {"available": False, "reason": str(exc), "path": executable_path}
1480 match = _BINARY_VERSION_PATTERN.match((result.stdout or "").strip())
1481 if not match:
1482 # A binary from before the identity flag existed, or a wrapper that prints
1483 # something else. Recorded as unavailable rather than guessed at.
1484 return {"available": False, "reason": "no build identity reported",
1485 "path": executable_path}
1486 return {
1487 "available": True,
1488 "path": executable_path,
1489 "release_version": match.group("release"),
1490 "git_commit": match.group("commit"),
1491 "dirty": bool(match.group("dirty")),
1492 "build_id": (
1493 f"{match.group('release')}+g{match.group('commit')}"
1494 f"{'.dirty' if match.group('dirty') else ''}"
1495 ),
1496 }
1497
1498
1500 """!
1501 @brief Read the build identity of every native executable a run would launch.
1502 @return Mapping of executable name to its identity, each carrying `matches_source`.
1503 """
1504 identities = {}
1505 source_commit = str(PICURV_BUILD.get("git_commit") or "")
1506 for name in ("simulator", "postprocessor"):
1508 if identity.get("available"):
1509 # The stamped commit is abbreviated, so compare on the prefix it carries.
1510 identity["matches_source"] = bool(
1511 source_commit
1512 and source_commit.startswith(identity["git_commit"])
1513 and identity["dirty"] == bool(PICURV_BUILD.get("dirty"))
1514 )
1515 identities[name] = identity
1516 return identities
1517
1518
1519def build_identity_problems(identities: dict, workspace_requirement=None) -> list:
1520 """!
1521 @brief Report every reason the active build identity is not internally coherent.
1522
1523 @details The conductor, the simulator, and the postprocessor are three artifacts
1524 that must agree before a run's provenance means anything. This states the
1525 disagreements rather than printing them, so `version status` can exit on
1526 them while ordinary staging only warns.
1527 @param[in] identities Mapping returned by `runtime_build_identities()`.
1528 @param[in] workspace_requirement Optional `software.picurv` constraint to check.
1529 @return Human-readable problem descriptions; empty when the build is coherent.
1530 """
1531 problems = []
1532 for name, identity in sorted(identities.items()):
1533 if not identity.get("available"):
1534 problems.append(
1535 f"{name}: no build identity available ({identity.get('reason', 'unknown')}); "
1536 "rebuild with 'make all'."
1537 )
1538 elif not identity.get("matches_source"):
1539 problems.append(
1540 f"{name}: built from {identity['build_id']}, but the active source is "
1541 f"{PICURV_BUILD['build_id']}; rebuild with 'make all'."
1542 )
1543 if workspace_requirement not in (None, ""):
1544 try:
1545 from packaging.specifiers import SpecifierSet
1546 from packaging.version import Version
1547 requirement_text = str(workspace_requirement).strip()
1548 if not any(token in requirement_text for token in "<>=!~"):
1549 requirement_text = "==" + requirement_text
1550 satisfied = Version(PICURV_RELEASE_VERSION) in SpecifierSet(requirement_text)
1551 except Exception:
1552 problems.append(
1553 f"workspace: software.picurv={workspace_requirement!r} is not a valid "
1554 "version or version range."
1555 )
1556 else:
1557 if not satisfied:
1558 problems.append(
1559 f"workspace: requires PICurv {workspace_requirement!r}, but the active "
1560 f"release is {PICURV_RELEASE_VERSION}."
1561 )
1562 return problems
1563
1564
1565def warn_on_stale_runtime_binaries(identities: dict) -> list:
1566 """!
1567 @brief Report native executables whose build identity is not the active source.
1568 @param[in] identities Mapping returned by `runtime_build_identities()`.
1569 @return Names of executables that disagree with the active source identity.
1570 """
1571 stale = [name for name, identity in sorted(identities.items())
1572 if identity.get("available") and not identity.get("matches_source")]
1573 for name in stale:
1574 print(
1575 f"[WARN] {name} was built from {identities[name]['build_id']}, but the active "
1576 f"source is {PICURV_BUILD['build_id']}. Checkpoints will record the binary's "
1577 "identity, not the source's. Run 'make all' to rebuild.",
1578 file=sys.stderr,
1579 )
1580 return stale
1581
1582# Standardized error codes used for CLI/validation reporting.
1583ERROR_CODE_CLI_USAGE_INVALID = "CLI_USAGE_INVALID"
1584ERROR_CODE_CFG_MISSING_SECTION = "CFG_MISSING_SECTION"
1585ERROR_CODE_CFG_MISSING_KEY = "CFG_MISSING_KEY"
1586ERROR_CODE_CFG_INVALID_TYPE = "CFG_INVALID_TYPE"
1587ERROR_CODE_CFG_INVALID_VALUE = "CFG_INVALID_VALUE"
1588ERROR_CODE_CFG_FILE_NOT_FOUND = "CFG_FILE_NOT_FOUND"
1589ERROR_CODE_CFG_GRID_PARSE = "CFG_GRID_PARSE"
1590ERROR_CODE_CFG_INCONSISTENT_COMBO = "CFG_INCONSISTENT_COMBO"
1591ERROR_CODE_DEPENDENCY_MISSING = "DEPENDENCY_MISSING"
1592
1593RESTART_RUN_DIR_REQUIRED_MESSAGE = (
1594 "--continue and --restart-from both require an existing run directory. "
1595 "Use --continue --run-dir <run_dir> to resume in place, or "
1596 "--restart-from <run_dir> to create a new run."
1597)
1598
1599_ERROR_HINTS = {
1600 ERROR_CODE_CLI_USAGE_INVALID: "Run 'picurv <command> --help' to see valid argument combinations.",
1601 ERROR_CODE_CFG_MISSING_SECTION: "Add the missing section using examples/master_template/*.yml as reference.",
1602 ERROR_CODE_CFG_MISSING_KEY: "Add the missing key in the referenced YAML file.",
1603 ERROR_CODE_CFG_INVALID_TYPE: "Fix the value type to match the documented schema in docs/pages/14_Config_Contract.md.",
1604 ERROR_CODE_CFG_INVALID_VALUE: "Adjust the value to a supported range/enum from the config reference pages.",
1605 ERROR_CODE_CFG_FILE_NOT_FOUND: "Fix the path or create the missing file before running again.",
1606 ERROR_CODE_CFG_GRID_PARSE: "Validate grid file format and numeric payload (block count, dims, coordinates).",
1607 ERROR_CODE_CFG_INCONSISTENT_COMBO: "Fix conflicting options/keys so the configuration is internally consistent.",
1608 ERROR_CODE_DEPENDENCY_MISSING: "Install the named optional dependency for the Python interpreter used by picurv.",
1609}
1610
1611
1612def _sanitize_error_field(value) -> str:
1613 """!
1614 @brief Normalize error fields into a single-line string.
1615 @param[in] value Argument passed to `_sanitize_error_field()`.
1616 @return Value returned by `_sanitize_error_field()`.
1617 """
1618 if value is None:
1619 return "-"
1620 text = str(value).strip()
1621 if not text:
1622 return "-"
1623 return " ".join(text.splitlines())
1624
1625
1626def emit_structured_error(code: str, key: str = "-", file_path: str = "-",
1627 message: str = "", hint: str = None, stream=None):
1628 """!
1629 @brief Emit one standardized error line for tooling and users.
1630 @param[in] code Argument passed to `emit_structured_error()`.
1631 @param[in] key Argument passed to `emit_structured_error()`.
1632 @param[in] file_path Argument passed to `emit_structured_error()`.
1633 @param[in] message Argument passed to `emit_structured_error()`.
1634 @param[in] hint Argument passed to `emit_structured_error()`.
1635 @param[in] stream Argument passed to `emit_structured_error()`.
1636 """
1637 if stream is None:
1638 stream = sys.stderr
1639 resolved_hint = hint if hint is not None else _ERROR_HINTS.get(code, "-")
1640 print(
1641 f"ERROR {_sanitize_error_field(code)} | "
1642 f"key={_sanitize_error_field(key)} | "
1643 f"file={_sanitize_error_field(file_path)} | "
1644 f"message={_sanitize_error_field(message)} | "
1645 f"hint={_sanitize_error_field(resolved_hint)}",
1646 file=stream,
1647 )
1648
1649
1650def fail_cli_usage(message: str, hint: str = None):
1651 """!
1652 @brief Emit a structured CLI usage error and exit with code 2.
1653 @param[in] message Argument passed to `fail_cli_usage()`.
1654 @param[in] hint Argument passed to `fail_cli_usage()`.
1655 """
1657 ERROR_CODE_CLI_USAGE_INVALID,
1658 key="-",
1659 file_path="-",
1660 message=message,
1661 hint=hint or _ERROR_HINTS[ERROR_CODE_CLI_USAGE_INVALID],
1662 )
1663 sys.exit(2)
1664
1665
1667 """!
1668 @brief Separate a validation error into its source-file and message fields when possible.
1669 @param[in] raw_error Validation error text, optionally beginning with a file path and colon.
1670 @return A pair containing the detected file path (or ``-``) and the message text.
1671 """
1672 text = str(raw_error).strip()
1673 match = re.match(r"^(?P<file>[^:]+):\s*(?P<msg>.+)$", text)
1674 if not match:
1675 return "-", text
1676 file_candidate = match.group("file").strip()
1677 msg = match.group("msg").strip()
1678 known_suffixes = (".yml", ".yaml", ".cfg", ".picgrid", ".control", ".run", ".txt")
1679 if "/" in file_candidate or file_candidate.endswith(known_suffixes):
1680 return file_candidate, msg
1681 return "-", text
1682
1683
1684def _extract_key_path(message: str) -> str:
1685 """!
1686 @brief Best-effort key-path extraction from free-form validation messages.
1687 @param[in] message Argument passed to `_extract_key_path()`.
1688 @return Value returned by `_extract_key_path()`.
1689 """
1690 dotted = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_\[\]-]+)+)\b", message)
1691 if dotted:
1692 return dotted.group(1)
1693
1694 bracketed = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*\[[^\]]+\](?:\[[^\]]+\])*)\b", message)
1695 if bracketed:
1696 return bracketed.group(1)
1697
1698 quoted = re.findall(r"'([A-Za-z0-9_.\[\]-]+)'", message)
1699 for token in quoted:
1700 if "." in token or "[" in token or token.isidentifier():
1701 return token
1702 return "-"
1703
1704
1705def _classify_error_code(message: str) -> str:
1706 """!
1707 @brief Map existing validation/error messages to the standardized code set.
1708 @param[in] message Argument passed to `_classify_error_code()`.
1709 @return Value returned by `_classify_error_code()`.
1710 """
1711 msg = message.lower()
1712 if "missing required section" in msg:
1713 return ERROR_CODE_CFG_MISSING_SECTION
1714 if "missing required key" in msg or "missing key" in msg:
1715 return ERROR_CODE_CFG_MISSING_KEY
1716 if "not found" in msg or "does not exist" in msg:
1717 return ERROR_CODE_CFG_FILE_NOT_FOUND
1718 if "invalid dimensions line" in msg or "invalid coordinate row" in msg or "grid file" in msg:
1719 return ERROR_CODE_CFG_GRID_PARSE
1720 if (
1721 "must both be periodic" in msg
1722 or "inconsistent periodicity" in msg
1723 or "mismatch" in msg
1724 or "requires --" in msg
1725 or "must be 1 (auto) or exactly" in msg
1726 ):
1727 return ERROR_CODE_CFG_INCONSISTENT_COMBO
1728 if (
1729 "must be a mapping" in msg
1730 or "must be a list" in msg
1731 or "must be a string" in msg
1732 or "must be a boolean" in msg
1733 or "must be either" in msg
1734 ):
1735 return ERROR_CODE_CFG_INVALID_TYPE
1736 if "unsupported key" in msg or "unsupported top-level section" in msg:
1737 return ERROR_CODE_CFG_INVALID_VALUE
1738 return ERROR_CODE_CFG_INVALID_VALUE
1739
1740# ==============================================================================
1741# HELPER FUNCTIONS
1742# ==============================================================================
1743
1744def read_yaml_file(filepath: str) -> dict:
1745 """!
1746 @brief Safely reads a YAML file and returns its content.
1747 @param[in] filepath Path to the YAML file.
1748 @return A dictionary containing the parsed YAML content.
1749 @throws SystemExit if the file is not found or cannot be parsed.
1750 """
1751 if not os.path.exists(filepath):
1753 ERROR_CODE_CFG_FILE_NOT_FOUND,
1754 key="-",
1755 file_path=filepath,
1756 message="Configuration file not found.",
1757 )
1758 sys.exit(1)
1759 try:
1760 with open(filepath, 'r') as f:
1761 return yaml.safe_load(f)
1762 except yaml.YAMLError as e:
1764 ERROR_CODE_CFG_INVALID_VALUE,
1765 key="-",
1766 file_path=filepath,
1767 message=f"YAML parse error: {e}",
1768 hint="Fix YAML syntax/indentation and retry validation.",
1769 )
1770 sys.exit(1)
1771
1772def write_yaml_file(filepath: str, data: dict):
1773 """!
1774 @brief Write YAML with stable ordering for generated study artifacts.
1775 @param[in] filepath Argument passed to `write_yaml_file()`.
1776 @param[in] data Argument passed to `write_yaml_file()`.
1777 """
1778 path = os.path.abspath(filepath)
1779 os.makedirs(os.path.dirname(path), exist_ok=True)
1780 temporary = f"{path}.tmp.{os.getpid()}"
1781 with open(temporary, "w") as f:
1782 yaml.safe_dump(data, f, sort_keys=False)
1783 f.flush()
1784 os.fsync(f.fileno())
1785 os.replace(temporary, path)
1786
1787def write_json_file(filepath: str, payload: dict):
1788 """!
1789 @brief Write JSON metadata/manifests with a stable, readable format.
1790 @param[in] filepath Argument passed to `write_json_file()`.
1791 @param[in] payload Argument passed to `write_json_file()`.
1792 """
1793 path = os.path.abspath(filepath)
1794 os.makedirs(os.path.dirname(path), exist_ok=True)
1795 temporary = f"{path}.tmp.{os.getpid()}"
1796 with open(temporary, "w") as f:
1797 json.dump(payload, f, indent=2, sort_keys=True)
1798 f.write("\n")
1799 f.flush()
1800 os.fsync(f.fileno())
1801 os.replace(temporary, path)
1802
1803
1804def write_runtime_execution_file(filepath: str, template_source_path: str = None) -> str:
1805 """!
1806 @brief Write a default runtime execution config, copying a source template when available.
1807 @param[in] filepath Argument passed to `write_runtime_execution_file()`.
1808 @param[in] template_source_path Argument passed to `write_runtime_execution_file()`.
1809 @return Value returned by `write_runtime_execution_file()`.
1810 """
1811 os.makedirs(os.path.dirname(filepath), exist_ok=True)
1812
1813 if template_source_path and os.path.isfile(template_source_path):
1814 shutil.copy2(template_source_path, filepath)
1815 return filepath
1816
1817 with open(filepath, "w", encoding="utf-8") as f:
1818 f.write(DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE)
1819 return filepath
1820
1821
1823 """!
1824 @brief Return True when a launcher arg token contains embedded whitespace and should be split.
1825 @param[in] token Argument passed to `_launcher_arg_contains_whitespace()`.
1826 @return Value returned by `_launcher_arg_contains_whitespace()`.
1827 """
1828 return isinstance(token, str) and any(ch.isspace() for ch in token.strip())
1829
1830
1831def resolve_runtime_execution_seed_source(source_project_root: str) -> "str | None":
1832 """!
1833 @brief Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
1834 @param[in] source_project_root Argument passed to `resolve_runtime_execution_seed_source()`.
1835 @return Value returned by `resolve_runtime_execution_seed_source()`.
1836 """
1837 source_root_abs = os.path.abspath(source_project_root)
1838 repo_local_runtime = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1839 if os.path.isfile(repo_local_runtime):
1840 return repo_local_runtime
1841
1842 tracked_example = os.path.join(
1843 source_root_abs,
1844 "config",
1845 "runtime",
1846 RUNTIME_EXECUTION_EXAMPLE_FILENAME,
1847 )
1848 if os.path.isfile(tracked_example):
1849 return tracked_example
1850 return None
1851
1852
1853def ensure_case_runtime_execution_config(case_dir: str, source_project_root: str, overwrite: bool = False) -> dict:
1854 """!
1855 @brief Create case-local runtime execution config if missing, seeded from repo-local config when available.
1856 @param[in] case_dir Argument passed to `ensure_case_runtime_execution_config()`.
1857 @param[in] source_project_root Argument passed to `ensure_case_runtime_execution_config()`.
1858 @param[in] overwrite Argument passed to `ensure_case_runtime_execution_config()`.
1859 @return Value returned by `ensure_case_runtime_execution_config()`.
1860 """
1861 case_dir_abs = os.path.abspath(case_dir)
1862 dest_path = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1863 if os.path.exists(dest_path) and not overwrite:
1864 return {
1865 "path": dest_path,
1866 "created": False,
1867 "seed_source": None,
1868 }
1869
1870 seed_source = resolve_runtime_execution_seed_source(source_project_root)
1871 write_runtime_execution_file(dest_path, seed_source)
1872 return {
1873 "path": dest_path,
1874 "created": True,
1875 "seed_source": seed_source,
1876 }
1877
1878
1879def is_project_root(candidate: str) -> bool:
1880 """!
1881 @brief Return True when a directory looks like the PICurv source repository root.
1882 @param[in] candidate Argument passed to `is_project_root()`.
1883 @return Value returned by `is_project_root()`.
1884 """
1885 if not candidate:
1886 return False
1887 candidate_abs = os.path.abspath(candidate)
1888 return (
1889 os.path.isfile(os.path.join(candidate_abs, "Makefile"))
1890 and os.path.isdir(os.path.join(candidate_abs, "src"))
1891 and os.path.isdir(os.path.join(candidate_abs, "include"))
1892 and os.path.isdir(os.path.join(candidate_abs, "picurv_cli"))
1893 )
1894
1895
1896def _iter_parent_dirs(start_path: str):
1897 """!
1898 @brief Yield a path and all of its parents up to filesystem root.
1899 @param[in] start_path Argument passed to `_iter_parent_dirs()`.
1900 """
1901 current = os.path.abspath(start_path)
1902 if os.path.isfile(current):
1903 current = os.path.dirname(current)
1904 while True:
1905 yield current
1906 parent = os.path.dirname(current)
1907 if parent == current:
1908 break
1909 current = parent
1910
1911
1912def find_project_root_upwards(start_path: str):
1913 """!
1914 @brief Search upward from an anchor and return the first matching project root.
1915 @param[in] start_path Argument passed to `find_project_root_upwards()`.
1916 @return Value returned by `find_project_root_upwards()`.
1917 """
1918 if not start_path:
1919 return None
1920 for directory in _iter_parent_dirs(start_path):
1921 if is_project_root(directory):
1922 return directory
1923 return None
1924
1925
1927 """!
1928 @brief Best-effort source repo discovery from runtime anchors.
1929 @param[in] extra_anchors Argument passed to `discover_local_project_root()`.
1930 @return Value returned by `discover_local_project_root()`.
1931 """
1932 anchors = list(extra_anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR, SCRIPT_PATH, PROJECT_ROOT]
1933 seen = set()
1934 for anchor in anchors:
1935 if not anchor:
1936 continue
1937 anchor_abs = os.path.abspath(anchor)
1938 if anchor_abs in seen:
1939 continue
1940 seen.add(anchor_abs)
1941 project_root = find_project_root_upwards(anchor_abs)
1942 if project_root:
1943 return project_root
1944 return None
1945
1946
1947def find_case_origin_metadata_file(case_dir_hint: str = None):
1948 """!
1949 @brief Find the nearest case-origin metadata file from known runtime anchors.
1950 @param[in] case_dir_hint Argument passed to `find_case_origin_metadata_file()`.
1951 @return Value returned by `find_case_origin_metadata_file()`.
1952 """
1953 search_roots = []
1954 for candidate in (case_dir_hint, os.getcwd(), INVOKED_SCRIPT_DIR):
1955 if not candidate:
1956 continue
1957 abs_candidate = os.path.abspath(candidate)
1958 if abs_candidate not in search_roots:
1959 search_roots.append(abs_candidate)
1960
1961 for root in search_roots:
1962 for directory in _iter_parent_dirs(root):
1963 metadata_path = os.path.join(directory, CASE_ORIGIN_METADATA_FILENAME)
1964 if os.path.isfile(metadata_path):
1965 return metadata_path
1966 return None
1967
1968
1969def load_case_origin_metadata(case_dir_hint: str = None):
1970 """!
1971 @brief Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
1972 @param[in] case_dir_hint Argument passed to `load_case_origin_metadata()`.
1973 @return Value returned by `load_case_origin_metadata()`.
1974 """
1975 metadata_path = find_case_origin_metadata_file(case_dir_hint)
1976 if not metadata_path:
1977 return None, None, None
1978 try:
1979 with open(metadata_path, "r", encoding="utf-8") as f:
1980 payload = json.load(f)
1981 if not isinstance(payload, dict):
1982 raise ValueError("Case origin metadata must be a JSON object.")
1983 except Exception as exc:
1984 raise ValueError(f"Failed to read case origin metadata at {metadata_path}: {exc}") from exc
1985 return os.path.dirname(metadata_path), metadata_path, payload
1986
1987
1989 """!
1990 @brief Find the nearest optional execution config from runtime/case anchors.
1991 @param[in] anchors Argument passed to `find_runtime_execution_config_file()`.
1992 @return Value returned by `find_runtime_execution_config_file()`.
1993 """
1994 seen = set()
1995 search_roots = []
1996 for candidate in list(anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR]:
1997 if not candidate:
1998 continue
1999 current = os.path.abspath(candidate)
2000 if os.path.isfile(current):
2001 current = os.path.dirname(current)
2002 if current in seen:
2003 continue
2004 seen.add(current)
2005 search_roots.append(current)
2006
2007 seen_dirs = set()
2008 for root in search_roots:
2009 for directory in _iter_parent_dirs(root):
2010 if directory in seen_dirs:
2011 continue
2012 seen_dirs.add(directory)
2013 for filename in RUNTIME_EXECUTION_CONFIG_FILENAMES:
2014 config_path = os.path.join(directory, filename)
2015 if os.path.isfile(config_path):
2016 return config_path
2017 return None
2018
2019
2020def _normalize_execution_override_section(payload: dict, section_name: str, config_path: str, config_label: str) -> dict:
2021 """!
2022 @brief Validate one execution override section while preserving missing-vs-empty semantics.
2023 @param[in] payload Argument passed to `_normalize_execution_override_section()`.
2024 @param[in] section_name Argument passed to `_normalize_execution_override_section()`.
2025 @param[in] config_path Argument passed to `_normalize_execution_override_section()`.
2026 @param[in] config_label Argument passed to `_normalize_execution_override_section()`.
2027 @return Value returned by `_normalize_execution_override_section()`.
2028 """
2029 section = payload.get(section_name)
2030 if section is None:
2031 return {"launcher": None, "launcher_args": None}
2032 if not isinstance(section, dict):
2033 raise ValueError(f"{config_label} at {config_path}: {section_name} must be a mapping.")
2034
2035 launcher = section.get("launcher")
2036 if launcher is not None and not isinstance(launcher, str):
2037 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher must be a string.")
2038
2039 launcher_args = None
2040 if "launcher_args" in section:
2041 launcher_args = section.get("launcher_args", [])
2042 if launcher_args is None:
2043 launcher_args = []
2044 if not isinstance(launcher_args, list):
2045 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher_args must be a list.")
2046 for i, token in enumerate(launcher_args):
2047 if not isinstance(token, (str, int, float)):
2048 raise ValueError(
2049 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2050 "must be a scalar CLI token."
2051 )
2053 raise ValueError(
2054 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2055 "must be a single CLI token; split whitespace-separated arguments into separate list items."
2056 )
2057 launcher_args = [str(x) for x in launcher_args]
2058
2059 return {
2060 "launcher": launcher,
2061 "launcher_args": launcher_args,
2062 }
2063
2064
2065def load_runtime_execution_config(config_search_anchor: str = None, extra_search_anchors=None):
2066 """!
2067 @brief Load optional shared execution launcher config from the nearest runtime config file.
2068 @param[in] config_search_anchor Argument passed to `load_runtime_execution_config()`.
2069 @param[in] extra_search_anchors Argument passed to `load_runtime_execution_config()`.
2070 @return Value returned by `load_runtime_execution_config()`.
2071 """
2072 anchors = []
2073 if config_search_anchor is not None:
2074 anchors.append(config_search_anchor)
2075 if extra_search_anchors:
2076 anchors.extend(extra_search_anchors)
2077
2078 config_path = find_runtime_execution_config_file(*anchors)
2079 if not config_path:
2080 return None, {}
2081
2082 try:
2083 with open(config_path, "r", encoding="utf-8") as f:
2084 payload = yaml.safe_load(f) or {}
2085 except yaml.YAMLError as exc:
2086 raise ValueError(f"{os.path.basename(config_path)} YAML parse error at {config_path}: {exc}") from exc
2087
2088 if not isinstance(payload, dict):
2089 raise ValueError(f"{os.path.basename(config_path)} at {config_path} must be a YAML mapping.")
2090
2091 return config_path, {
2092 "default_execution": _normalize_execution_override_section(
2093 payload,
2094 "default_execution",
2095 config_path,
2096 os.path.basename(config_path),
2097 ),
2098 "local_execution": _normalize_execution_override_section(
2099 payload,
2100 "local_execution",
2101 config_path,
2102 os.path.basename(config_path),
2103 ),
2104 "cluster_execution": _normalize_execution_override_section(
2105 payload,
2106 "cluster_execution",
2107 config_path,
2108 os.path.basename(config_path),
2109 ),
2110 }
2111
2112
2113def merge_execution_overrides(base: "dict | None", override: "dict | None") -> dict:
2114 """!
2115 @brief Merge execution overrides, letting explicit override values win key-by-key.
2116 @param[in] base Argument passed to `merge_execution_overrides()`.
2117 @param[in] override Argument passed to `merge_execution_overrides()`.
2118 @return Value returned by `merge_execution_overrides()`.
2119 """
2120 base = base or {}
2121 override = override or {}
2122
2123 launcher = override.get("launcher")
2124 if launcher is None:
2125 launcher = base.get("launcher")
2126
2127 launcher_args = override.get("launcher_args")
2128 if launcher_args is None:
2129 launcher_args = base.get("launcher_args")
2130
2131 return {
2132 "launcher": launcher,
2133 "launcher_args": None if launcher_args is None else [str(x) for x in launcher_args],
2134 }
2135
2136
2137def resolve_runtime_execution_context(runtime_execution_cfg: dict, context: str) -> dict:
2138 """!
2139 @brief Resolve default plus context-specific execution overrides.
2140 @param[in] runtime_execution_cfg Argument passed to `resolve_runtime_execution_context()`.
2141 @param[in] context Argument passed to `resolve_runtime_execution_context()`.
2142 @return Value returned by `resolve_runtime_execution_context()`.
2143 """
2144 if context not in LAUNCH_MODES:
2145 raise ValueError(f"Unsupported execution context '{context}'.")
2147 runtime_execution_cfg.get("default_execution"),
2148 runtime_execution_cfg.get(f"{context}_execution"),
2149 )
2150
2151
2152def get_git_commit(repo_root: str = None) -> str:
2153 """!
2154 @brief Best-effort git commit lookup for run/study manifests and case metadata.
2155 @param[in] repo_root Argument passed to `get_git_commit()`.
2156 @return Value returned by `get_git_commit()`.
2157 """
2158 cwd = repo_root or PROJECT_ROOT
2159 try:
2160 result = subprocess.run(
2161 ["git", "rev-parse", "HEAD"],
2162 cwd=cwd,
2163 text=True,
2164 capture_output=True,
2165 check=False
2166 )
2167 if result.returncode == 0:
2168 return result.stdout.strip()
2169 except Exception:
2170 pass
2171 return None
2172
2173
2174def write_case_origin_metadata(case_dir: str, source_project_root: str, template_name: str = None,
2175 existing: dict = None, template_managed_files=None):
2176 """!
2177 @brief Create or refresh case-origin metadata for repo-aware case maintenance commands.
2178 @param[in] case_dir Argument passed to `write_case_origin_metadata()`.
2179 @param[in] source_project_root Argument passed to `write_case_origin_metadata()`.
2180 @param[in] template_name Argument passed to `write_case_origin_metadata()`.
2181 @param[in] existing Argument passed to `write_case_origin_metadata()`.
2182 @param[in] template_managed_files Argument passed to `write_case_origin_metadata()`.
2183 @return Value returned by `write_case_origin_metadata()`.
2184 """
2185 payload = dict(existing or {})
2186 if "initialized_at" not in payload:
2187 payload["initialized_at"] = datetime.now().isoformat()
2188 payload["source_repo_root"] = os.path.abspath(source_project_root)
2189 if template_name:
2190 payload["template_name"] = template_name
2191 if template_managed_files is not None:
2192 payload["template_managed_files"] = sorted(set(str(p) for p in template_managed_files))
2193 payload["last_known_source_git_commit"] = get_git_commit(source_project_root)
2194 metadata_path = os.path.join(os.path.abspath(case_dir), CASE_ORIGIN_METADATA_FILENAME)
2195 write_json_file(metadata_path, payload)
2196 return metadata_path, payload
2197
2198
2199def make_args_include_explicit_goal(make_args: "list[str]") -> bool:
2200 """!
2201 @brief Return True when make args contain an explicit target rather than only options/assignments.
2202 @param[in] make_args Argument passed to `make_args_include_explicit_goal()`.
2203 @return Value returned by `make_args_include_explicit_goal()`.
2204 """
2205 if not make_args:
2206 return False
2207
2208 options_with_value = {
2209 "-C", "-f", "-I", "-j", "-l", "-o", "-W",
2210 "--directory", "--file", "--makefile", "--include-dir", "--jobs",
2211 "--load-average", "--max-load", "--old-file", "--assume-old",
2212 "--what-if", "--new-file", "--assume-new",
2213 }
2214 assignment_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
2215
2216 skip_next = False
2217 for token in make_args:
2218 if skip_next:
2219 skip_next = False
2220 continue
2221 if token in options_with_value:
2222 skip_next = True
2223 continue
2224 if token.startswith("-"):
2225 continue
2226 if assignment_pattern.match(token):
2227 continue
2228 return True
2229 return False
2230
2231
2232def resolve_case_origin_context(case_dir_hint: str = None, source_root_override: str = None, template_name_override: str = None):
2233 """!
2234 @brief Resolve case directory, source repo root, and optional template metadata.
2235 @param[in] case_dir_hint Argument passed to `resolve_case_origin_context()`.
2236 @param[in] source_root_override Argument passed to `resolve_case_origin_context()`.
2237 @param[in] template_name_override Argument passed to `resolve_case_origin_context()`.
2238 @return Value returned by `resolve_case_origin_context()`.
2239 """
2240 metadata_case_dir, metadata_path, metadata = load_case_origin_metadata(case_dir_hint)
2241
2242 if metadata_case_dir:
2243 case_dir = metadata_case_dir
2244 else:
2245 case_dir = os.path.abspath(case_dir_hint or os.getcwd())
2246
2247 source_project_root = source_root_override
2248 if source_project_root:
2249 source_project_root = os.path.abspath(source_project_root)
2250 elif isinstance(metadata, dict) and isinstance(metadata.get("source_repo_root"), str):
2251 source_project_root = os.path.abspath(metadata["source_repo_root"])
2252 else:
2253 source_project_root = discover_local_project_root(case_dir)
2254
2255 template_name = template_name_override
2256 if not template_name and isinstance(metadata, dict):
2257 template_name = metadata.get("template_name")
2258
2259 return {
2260 "case_dir": case_dir,
2261 "metadata_path": metadata_path,
2262 "metadata": metadata or {},
2263 "source_project_root": source_project_root,
2264 "template_name": template_name,
2265 }
2266
2267
2268def require_project_root(candidate: str, purpose: str):
2269 """!
2270 @brief Validate that a source repo root was resolved and is structurally valid.
2271 @param[in] candidate Argument passed to `require_project_root()`.
2272 @param[in] purpose Argument passed to `require_project_root()`.
2273 @return Value returned by `require_project_root()`.
2274 """
2275 if not candidate:
2276 raise ValueError(
2277 f"Could not determine the PICurv source repository for {purpose}. "
2278 "Run this command from an initialized case directory or pass --source-root."
2279 )
2280 candidate_abs = os.path.abspath(candidate)
2281 if not is_project_root(candidate_abs):
2282 raise ValueError(
2283 f"Resolved source repository for {purpose} is not a valid PICurv root: {candidate_abs}"
2284 )
2285 return candidate_abs
2286
2287
2288def require_existing_case_dir(case_dir: str, purpose: str, source_project_root: str = None):
2289 """!
2290 @brief Validate that a target case directory exists and is not the source repo root.
2291 @param[in] case_dir Argument passed to `require_existing_case_dir()`.
2292 @param[in] purpose Argument passed to `require_existing_case_dir()`.
2293 @param[in] source_project_root Argument passed to `require_existing_case_dir()`.
2294 @return Value returned by `require_existing_case_dir()`.
2295 """
2296 if not case_dir:
2297 raise ValueError(f"Could not determine the case directory for {purpose}. Pass --case-dir.")
2298 case_dir_abs = os.path.abspath(case_dir)
2299 if not os.path.isdir(case_dir_abs):
2300 raise ValueError(f"Case directory for {purpose} does not exist: {case_dir_abs}")
2301 if source_project_root and os.path.abspath(source_project_root) == case_dir_abs:
2302 raise ValueError(
2303 f"Refusing to run {purpose} against the source repository root itself: {case_dir_abs}"
2304 )
2305 return case_dir_abs
2306
2307
2308def resolve_template_directory(source_project_root: str, template_name: str):
2309 """!
2310 @brief Resolve an example template directory inside the source repository.
2311 @param[in] source_project_root Argument passed to `resolve_template_directory()`.
2312 @param[in] template_name Argument passed to `resolve_template_directory()`.
2313 @return Value returned by `resolve_template_directory()`.
2314 """
2315 if not template_name:
2316 raise ValueError(
2317 "Template name is required for config sync. Re-run with --template-name or from a case initialized by current picurv."
2318 )
2319 template_dir = os.path.join(source_project_root, "examples", template_name)
2320 if not os.path.isdir(template_dir):
2321 raise ValueError(f"Case template '{template_name}' not found at '{template_dir}'")
2322 return template_dir
2323
2324
2325def list_template_relative_files(template_dir: str, excluded_rel_paths=None):
2326 """!
2327 @brief List all files in a template directory as case-relative paths.
2328 @param[in] template_dir Argument passed to `list_template_relative_files()`.
2329 @param[in] excluded_rel_paths Argument passed to `list_template_relative_files()`.
2330 @return Value returned by `list_template_relative_files()`.
2331 """
2332 template_dir_abs = os.path.abspath(template_dir)
2333 if not os.path.isdir(template_dir_abs):
2334 raise ValueError(f"Template directory not found: {template_dir_abs}")
2335 excluded = set(excluded_rel_paths or [])
2336 relative_paths = []
2337 for root, _, files in os.walk(template_dir_abs):
2338 rel_root = os.path.relpath(root, template_dir_abs)
2339 for filename in sorted(files):
2340 rel_path = filename if rel_root == "." else os.path.join(rel_root, filename)
2341 if rel_path in excluded:
2342 continue
2343 relative_paths.append(rel_path)
2344 return relative_paths
2345
2346
2347def list_source_binaries(source_project_root: str):
2348 """!
2349 @brief List binary artifacts currently available in the source repo bin directory.
2350 @param[in] source_project_root Argument passed to `list_source_binaries()`.
2351 @return Value returned by `list_source_binaries()`.
2352 """
2353 source_bin_dir = os.path.join(os.path.abspath(source_project_root), "bin")
2354 if not os.path.isdir(source_bin_dir):
2355 raise ValueError(f"Source bin directory not found: {source_bin_dir}. Run 'picurv build' first.")
2356 binaries = sorted(
2357 f for f in os.listdir(source_bin_dir)
2358 if os.path.isfile(os.path.join(source_bin_dir, f)) and f != "picurv"
2359 )
2360 if not binaries:
2361 raise ValueError(f"Source bin directory contains no files: {source_bin_dir}")
2362 return source_bin_dir, binaries
2363
2364
2365def sync_case_binaries(case_dir: str, source_project_root: str):
2366 """!
2367 @brief Copy current source-repo binaries into a case directory for version-pinning.
2368 @param[in] case_dir Argument passed to `sync_case_binaries()`.
2369 @param[in] source_project_root Argument passed to `sync_case_binaries()`.
2370 @return Value returned by `sync_case_binaries()`.
2371 """
2372 case_dir_abs = os.path.abspath(case_dir)
2373 os.makedirs(case_dir_abs, exist_ok=True)
2374 source_bin_dir, binaries = list_source_binaries(source_project_root)
2375 copied = []
2376 for binary_name in binaries:
2377 source_path = os.path.join(source_bin_dir, binary_name)
2378 dest_path = os.path.join(case_dir_abs, binary_name)
2379 shutil.copy2(source_path, dest_path)
2380 copied.append(dest_path)
2381 return copied
2382
2383
2384def sync_case_template_files(case_dir: str, template_dir: str, overwrite: bool = False,
2385 prune: bool = False, managed_rel_paths=None):
2386 """!
2387 @brief Sync template files into a case directory, preserving modified files unless overwrite is requested.
2388 @param[in] case_dir Argument passed to `sync_case_template_files()`.
2389 @param[in] template_dir Argument passed to `sync_case_template_files()`.
2390 @param[in] overwrite Argument passed to `sync_case_template_files()`.
2391 @param[in] prune Argument passed to `sync_case_template_files()`.
2392 @param[in] managed_rel_paths Argument passed to `sync_case_template_files()`.
2393 @return Value returned by `sync_case_template_files()`.
2394 """
2395 case_dir_abs = os.path.abspath(case_dir)
2396 template_dir_abs = os.path.abspath(template_dir)
2397 if not os.path.isdir(template_dir_abs):
2398 raise ValueError(f"Template directory not found: {template_dir_abs}")
2399
2400 summary = {
2401 "copied": [],
2402 "overwritten": [],
2403 "skipped_modified": [],
2404 "unchanged": [],
2405 "pruned": [],
2406 "prune_requested_without_tracking": False,
2407 }
2408 excluded_rel_paths = {RUNTIME_EXECUTION_EXAMPLE_FILENAME}
2409 current_template_files = list_template_relative_files(
2410 template_dir_abs,
2411 excluded_rel_paths=excluded_rel_paths,
2412 )
2413 current_template_set = set(current_template_files)
2414
2415 for root, _, files in os.walk(template_dir_abs):
2416 rel_root = os.path.relpath(root, template_dir_abs)
2417 for filename in sorted(files):
2418 src_path = os.path.join(root, filename)
2419 rel_path = filename if rel_root == "." else os.path.join(rel_root, filename)
2420 if rel_path in excluded_rel_paths:
2421 continue
2422 dest_path = os.path.join(case_dir_abs, rel_path)
2423 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
2424
2425 if not os.path.exists(dest_path):
2426 shutil.copy2(src_path, dest_path)
2427 summary["copied"].append(dest_path)
2428 continue
2429
2430 if filecmp.cmp(src_path, dest_path, shallow=False):
2431 summary["unchanged"].append(dest_path)
2432 continue
2433
2434 if overwrite:
2435 shutil.copy2(src_path, dest_path)
2436 summary["overwritten"].append(dest_path)
2437 else:
2438 summary["skipped_modified"].append(dest_path)
2439
2440 managed_set = set(managed_rel_paths or [])
2441 if prune:
2442 if not managed_set:
2443 summary["prune_requested_without_tracking"] = True
2444 for rel_path in sorted(managed_set - current_template_set):
2445 dest_path = os.path.join(case_dir_abs, rel_path)
2446 if os.path.isfile(dest_path):
2447 os.remove(dest_path)
2448 summary["pruned"].append(dest_path)
2449
2450 summary["template_managed_files"] = current_template_files
2451 return summary
2452
2453
2454def compute_case_source_status(case_dir: str, source_project_root: str, template_name: str = None, metadata: dict = None):
2455 """!
2456 @brief Compute source/case drift across commits, binaries, and template-managed files.
2457 @param[in] case_dir Argument passed to `compute_case_source_status()`.
2458 @param[in] source_project_root Argument passed to `compute_case_source_status()`.
2459 @param[in] template_name Argument passed to `compute_case_source_status()`.
2460 @param[in] metadata Argument passed to `compute_case_source_status()`.
2461 @return Value returned by `compute_case_source_status()`.
2462 """
2463 case_dir_abs = os.path.abspath(case_dir)
2464 source_root_abs = os.path.abspath(source_project_root)
2465 metadata = metadata or {}
2466 status = {
2467 "case_dir": case_dir_abs,
2468 "source_repo_root": source_root_abs,
2469 "metadata_present": bool(metadata),
2470 "template_name": template_name,
2471 "last_known_source_git_commit": metadata.get("last_known_source_git_commit"),
2472 "current_source_git_commit": get_git_commit(source_root_abs),
2473 }
2474 status["source_commit_changed"] = (
2475 bool(status["last_known_source_git_commit"])
2476 and bool(status["current_source_git_commit"])
2477 and status["last_known_source_git_commit"] != status["current_source_git_commit"]
2478 )
2479
2480 binary_status = {
2481 "source_bin_present": False,
2482 "source_bin_missing": [],
2483 "case_bin_missing": [],
2484 "case_bin_different": [],
2485 "case_bin_current": [],
2486 }
2487 try:
2488 source_bin_dir, binaries = list_source_binaries(source_root_abs)
2489 binary_status["source_bin_present"] = True
2490 for binary_name in binaries:
2491 source_path = os.path.join(source_bin_dir, binary_name)
2492 case_path = os.path.join(case_dir_abs, binary_name)
2493 if not os.path.isfile(case_path):
2494 binary_status["case_bin_missing"].append(binary_name)
2495 elif filecmp.cmp(source_path, case_path, shallow=False):
2496 binary_status["case_bin_current"].append(binary_name)
2497 else:
2498 binary_status["case_bin_different"].append(binary_name)
2499 except ValueError as exc:
2500 binary_status["source_bin_missing"].append(str(exc))
2501 status["binaries"] = binary_status
2502
2503 config_status = {
2504 "template_available": False,
2505 "template_files": [],
2506 "case_missing_files": [],
2507 "case_modified_files": [],
2508 "case_current_files": [],
2509 "template_removed_since_last_sync": [],
2510 "tracking_available": isinstance(metadata.get("template_managed_files"), list),
2511 }
2512 if template_name:
2513 try:
2514 template_dir = resolve_template_directory(source_root_abs, template_name)
2515 template_files = list_template_relative_files(
2516 template_dir,
2517 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
2518 )
2519 config_status["template_available"] = True
2520 config_status["template_files"] = template_files
2521 for rel_path in template_files:
2522 src_path = os.path.join(template_dir, rel_path)
2523 case_path = os.path.join(case_dir_abs, rel_path)
2524 if not os.path.isfile(case_path):
2525 config_status["case_missing_files"].append(rel_path)
2526 elif filecmp.cmp(src_path, case_path, shallow=False):
2527 config_status["case_current_files"].append(rel_path)
2528 else:
2529 config_status["case_modified_files"].append(rel_path)
2530 managed_files = metadata.get("template_managed_files")
2531 if isinstance(managed_files, list):
2532 config_status["template_removed_since_last_sync"] = sorted(set(managed_files) - set(template_files))
2533 except ValueError:
2534 pass
2535 status["config"] = config_status
2536
2537 case_runtime_cfg = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2538 repo_runtime_seed = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2539 runtime_status = {
2540 "case_config_present": os.path.isfile(case_runtime_cfg),
2541 "repo_seed_present": os.path.isfile(repo_runtime_seed),
2542 "case_matches_repo_seed": False,
2543 }
2544 if runtime_status["case_config_present"] and runtime_status["repo_seed_present"]:
2545 runtime_status["case_matches_repo_seed"] = filecmp.cmp(
2546 case_runtime_cfg,
2547 repo_runtime_seed,
2548 shallow=False,
2549 )
2550 status["runtime_execution"] = runtime_status
2551 return status
2552
2553
2555 """!
2556 @brief Render human-readable source/case drift details.
2557 @param[in] status Argument passed to `print_case_source_status()`.
2558 """
2559 print(f"[INFO] Case directory : {status['case_dir']}")
2560 print(f"[INFO] Source repo : {status['source_repo_root']}")
2561 print(f"[INFO] Template : {status.get('template_name') or '(unknown)'}")
2562 if status.get("last_known_source_git_commit"):
2563 print(f"[INFO] Last synced commit : {status['last_known_source_git_commit']}")
2564 if status.get("current_source_git_commit"):
2565 print(f"[INFO] Current src commit : {status['current_source_git_commit']}")
2566 print(f"[INFO] Source changed : {'yes' if status.get('source_commit_changed') else 'no'}")
2567
2568 binaries = status["binaries"]
2569 if binaries["source_bin_present"]:
2570 print(
2571 f"[INFO] Binaries : current={len(binaries['case_bin_current'])} "
2572 f"changed={len(binaries['case_bin_different'])} missing={len(binaries['case_bin_missing'])}"
2573 )
2574 else:
2575 print("[INFO] Binaries : source bin/ unavailable")
2576
2577 config = status["config"]
2578 if config["template_available"]:
2579 print(
2580 f"[INFO] Template files : current={len(config['case_current_files'])} "
2581 f"modified={len(config['case_modified_files'])} missing={len(config['case_missing_files'])}"
2582 )
2583 if config["tracking_available"]:
2584 print(f"[INFO] Prune candidates : {len(config['template_removed_since_last_sync'])}")
2585 else:
2586 print("[INFO] Prune candidates : tracking unavailable")
2587 elif status.get("template_name"):
2588 print("[INFO] Template files : template unavailable in source repo")
2589
2590 runtime_cfg = status.get("runtime_execution", {})
2591 print(
2592 f"[INFO] Runtime config : case={'yes' if runtime_cfg.get('case_config_present') else 'no'} "
2593 f"repo-seed={'yes' if runtime_cfg.get('repo_seed_present') else 'no'} "
2594 f"matches-repo-seed={'yes' if runtime_cfg.get('case_matches_repo_seed') else 'no'}"
2595 )
2596
2597
2599 """!
2600 @brief Report source/case drift for an initialized case directory.
2601 @param[in] args Command-line style argument list supplied to the function.
2602 """
2603 try:
2605 case_dir_hint=getattr(args, "case_dir", None),
2606 source_root_override=getattr(args, "source_root", None),
2607 template_name_override=getattr(args, "template_name", None),
2608 )
2609 source_project_root = require_project_root(context["source_project_root"], "status-source")
2610 case_dir = require_existing_case_dir(context["case_dir"], "status-source", source_project_root)
2612 case_dir,
2613 source_project_root,
2614 template_name=context.get("template_name"),
2615 metadata=context.get("metadata"),
2616 )
2617 except ValueError as exc:
2618 print(f"[FATAL] {exc}", file=sys.stderr)
2619 sys.exit(1)
2620
2621 if getattr(args, "output_format", "text") == "json":
2622 print(json.dumps(status, indent=2, sort_keys=True))
2623 return
2625
2626def resolve_path(anchor_file: str, candidate: str) -> str:
2627 """!
2628 @brief Resolve a potentially relative path against a source YAML file path.
2629 @param[in] anchor_file Argument passed to `resolve_path()`.
2630 @param[in] candidate Argument passed to `resolve_path()`.
2631 @return Value returned by `resolve_path()`.
2632 """
2633 if candidate is None:
2634 return None
2635 return resolve_workspace_path(anchor_file, candidate)
2636
2637
2638POST_RUN_CONTROL_ALIASES = {
2639 "start_step": ("start_step", "startTime"),
2640 "end_step": ("end_step", "endTime"),
2641 "step_interval": ("step_interval", "timeStep"),
2642}
2643
2644
2645GRID_GENERATOR_HYPHEN_KEY_HINTS = {
2646 "config-file": "config_file",
2647 "grid-type": "grid_type",
2648 "cli-args": "cli_args",
2649}
2650
2651#: Generator keys that used to name their own output destinations. PICurv owns where
2652#: generated artifacts go, so accepting these would let a configuration file create a
2653#: competing output directory outside the asset store.
2654RETIRED_GENERATOR_DESTINATION_KEYS = ("output_file", "stats_file", "vts_file",
2655 "output-file", "stats-file", "vts-file")
2656
2657
2658def _mapping_value_with_aliases(mapping: dict, *keys, default=None):
2659 """!
2660 @brief Return the first defined value from a mapping across alias keys.
2661 @param[in] mapping Argument passed to `_mapping_value_with_aliases()`.
2662 @param[in] default Argument passed to `_mapping_value_with_aliases()`.
2663 @param[in] keys Argument passed to `_mapping_value_with_aliases()`.
2664 @return Value returned by `_mapping_value_with_aliases()`.
2665 """
2666 if not isinstance(mapping, dict):
2667 return default
2668 for key in keys:
2669 if key in mapping:
2670 return mapping.get(key)
2671 return default
2672
2673
2674def get_post_run_control_value(post_cfg: dict, canonical_key: str, default=None):
2675 """!
2676 @brief Resolve post run_control values with backwards-compatible legacy aliases.
2677 @param[in] post_cfg Argument passed to `get_post_run_control_value()`.
2678 @param[in] canonical_key Argument passed to `get_post_run_control_value()`.
2679 @param[in] default Argument passed to `get_post_run_control_value()`.
2680 @return Value returned by `get_post_run_control_value()`.
2681 """
2682 aliases = POST_RUN_CONTROL_ALIASES.get(canonical_key, (canonical_key,))
2683 rc = post_cfg.get("run_control", {})
2684 return _mapping_value_with_aliases(rc, *aliases, default=default)
2685
2686
2687def warn_on_grid_generator_hyphen_keys(generator: dict, case_path: str, warnings: list) -> None:
2688 """!
2689 @brief Warn when grid.generator uses unsupported hyphenated wrapper keys.
2690 @param[in] generator grid.generator mapping from case.yml.
2691 @param[in] case_path Case file path for diagnostics.
2692 @param[in,out] warnings Warning list to append to.
2693 """
2694 if not isinstance(generator, dict):
2695 return
2696 for bad_key, expected_key in GRID_GENERATOR_HYPHEN_KEY_HINTS.items():
2697 if bad_key in generator and bad_key != expected_key:
2698 warnings.append(
2699 f"{case_path}: grid.generator.{bad_key} is ignored; use grid.generator.{expected_key}."
2700 )
2701
2702
2703def reject_generator_destination_keys(generator, case_path: str, label: str) -> list:
2704 """!
2705 @brief Reject generator settings that try to choose their own output destination.
2706 @param[in] generator Generator mapping from the case configuration.
2707 @param[in] case_path Case file path for diagnostics.
2708 @param[in] label Dotted configuration path being checked, for the message.
2709 @return List of error strings.
2710 """
2711 if not isinstance(generator, dict):
2712 return []
2713 return [
2714 f" {case_path}: '{label}.{key}' is no longer accepted. PICurv chooses where "
2715 "generated artifacts go; the published asset carries the payload, its preview, "
2716 "and its validation record."
2717 for key in RETIRED_GENERATOR_DESTINATION_KEYS if key in generator
2718 ]
2719
2720
2721def get_post_source_data(post_cfg: dict):
2722 """!
2723 @brief Return source_data as a mapping when valid, else an empty mapping.
2724 @param[in] post_cfg Argument passed to `get_post_source_data()`.
2725 @return Value returned by `get_post_source_data()`.
2726 """
2727 source_cfg = post_cfg.get("source_data", {})
2728 if isinstance(source_cfg, dict):
2729 return source_cfg
2730 return {}
2731
2732
2733def get_post_source_directory_template(post_cfg: dict, default: str = "<solver_output_dir>") -> str:
2734 """!
2735 @brief Resolve the source directory template from source_data with a safe default.
2736 @param[in] post_cfg Argument passed to `get_post_source_directory_template()`.
2737 @param[in] default Argument passed to `get_post_source_directory_template()`.
2738 @return Value returned by `get_post_source_directory_template()`.
2739 """
2740 return get_post_source_data(post_cfg).get("directory", default)
2741
2742
2743def get_post_input_extensions(post_cfg: dict):
2744 """!
2745 @brief Return post input_extensions, preferring io.* and tolerating legacy source_data.* placement.
2746 @param[in] post_cfg Argument passed to `get_post_input_extensions()`.
2747 @return Value returned by `get_post_input_extensions()`.
2748 """
2749 io_cfg = post_cfg.get("io", {})
2750 io_ext = io_cfg.get("input_extensions") if isinstance(io_cfg, dict) else None
2751 if isinstance(io_ext, dict):
2752 return io_ext
2753
2754 source_ext = get_post_source_data(post_cfg).get("input_extensions")
2755 if isinstance(source_ext, dict):
2756 return source_ext
2757
2758 return {}
2759
2760
2762 """!
2763 @brief Return normalized statistics pipeline tokens that will be written into post.run.
2764 @param[in] post_cfg Argument passed to `get_post_statistics_task_tokens()`.
2765 @return Value returned by `get_post_statistics_task_tokens()`.
2766 """
2767 stats_cfg = post_cfg.get("statistics_pipeline")
2768 stats_entries = []
2769 if isinstance(stats_cfg, list):
2770 stats_entries = stats_cfg
2771 elif isinstance(stats_cfg, dict):
2772 stats_entries = stats_cfg.get("tasks", [])
2773
2774 tokens = []
2775 for entry in stats_entries:
2776 if isinstance(entry, str):
2777 task_name = entry
2778 elif isinstance(entry, dict):
2779 task_name = entry.get("task")
2780 else:
2781 continue
2782 try:
2783 tokens.append(normalize_statistics_task(task_name))
2784 except ValueError:
2785 continue
2786 return tokens
2787
2788
2789def get_monitor_output_directory(monitor_cfg: dict, default: str = "output") -> str:
2790 """!
2791 @brief Resolve the solver output root from monitor.yml, preserving the default layout.
2792 @param[in] monitor_cfg Argument passed to `get_monitor_output_directory()`.
2793 @param[in] default Argument passed to `get_monitor_output_directory()`.
2794 @return Value returned by `get_monitor_output_directory()`.
2795 """
2796 del monitor_cfg, default
2797 return CANONICAL_RUN_PATHS["output"]
2798
2799
2800def get_post_statistics_output_prefix(post_cfg: dict, default: str = "Stats") -> str:
2801 """!
2802 @brief Resolve the statistics CSV prefix, preserving legacy top-level override support.
2803 @param[in] post_cfg Argument passed to `get_post_statistics_output_prefix()`.
2804 @param[in] default Argument passed to `get_post_statistics_output_prefix()`.
2805 @return Value returned by `get_post_statistics_output_prefix()`.
2806 """
2807 stats_cfg = post_cfg.get("statistics_pipeline")
2808 if isinstance(stats_cfg, dict):
2809 prefix = stats_cfg.get("output_prefix")
2810 if isinstance(prefix, str) and prefix.strip():
2811 return prefix.strip()
2812
2813 legacy_prefix = post_cfg.get("statistics_output_prefix")
2814 if isinstance(legacy_prefix, str) and legacy_prefix.strip():
2815 return legacy_prefix.strip()
2816
2817 return default
2818
2819
2820def resolve_post_statistics_output_prefix(post_cfg: dict, monitor_cfg=None, default: str = "Stats") -> str:
2821 """!
2822 @brief Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
2823 @param[in] post_cfg Argument passed to `resolve_post_statistics_output_prefix()`.
2824 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
2825 @param[in] default Argument passed to `resolve_post_statistics_output_prefix()`.
2826 @return Value returned by `resolve_post_statistics_output_prefix()`.
2827 """
2828 prefix = get_post_statistics_output_prefix(post_cfg, default=default).strip()
2829 if os.path.isabs(prefix):
2830 return prefix
2831
2832 if os.path.dirname(prefix):
2833 return prefix
2834
2835 del monitor_cfg
2836 return os.path.join(CANONICAL_RUN_PATHS["statistics"], prefix)
2837
2838
2839def get_post_statistics_output_artifacts(post_cfg: dict, run_dir: str, monitor_cfg=None):
2840 """!
2841 @brief Predict statistics CSV output paths relative to the postprocessor runtime cwd.
2842 @param[in] post_cfg Argument passed to `get_post_statistics_output_artifacts()`.
2843 @param[in] run_dir Argument passed to `get_post_statistics_output_artifacts()`.
2844 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
2845 @return Value returned by `get_post_statistics_output_artifacts()`.
2846 """
2847 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
2848 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
2849 token_to_suffix = {
2850 "ComputeMSD": "_msd.csv",
2851 }
2852 prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
2853 if os.path.isabs(prefix):
2854 base_path = os.path.abspath(prefix)
2855 else:
2856 base_path = os.path.abspath(os.path.join(run_dir, prefix))
2857
2858 output_paths = []
2859 for token in get_post_statistics_task_tokens(post_cfg):
2860 suffix = token_to_suffix.get(token)
2861 if suffix:
2862 output_paths.append(base_path + suffix)
2863
2864 return list(dict.fromkeys(output_paths))
2865
2866
2867#: Spectra tasks a post recipe may request, with the preconditions each needs.
2868#:
2869#: `requires_uniform_cartesian` is checked against the staged PICGRID and
2870#: `requires_periodic_geometric` against the resolved boundary conditions, both
2871#: before any field is read. A task whose preconditions the case cannot meet is
2872#: refused at validation rather than producing a curve with no meaning: a spectrum
2873#: needs the transform direction to be uniformly spaced, periodic, and statistically
2874#: homogeneous, and a curvilinear or wall-bounded geometry supplies none of those.
2875POST_SPECTRA_TASKS = {
2876 "shell_spectrum": {
2877 "requires_uniform_cartesian": True,
2878 "requires_periodic_geometric": True,
2879 "requires_single_block": True,
2880 "fields": ("Ucat",),
2881 },
2882}
2883
2884#: Binning abscissae a spectra task may request.
2885POST_SPECTRA_SYMBOLS = ("continuum", "discrete")
2886
2887#: Fluctuation definitions a spectra task may request. `window:<name>` is accepted
2888#: as a prefixed form and resolved against the accumulated windows.
2889POST_SPECTRA_MEAN_MODES = ("none", "domain")
2890
2891
2892#: Derived outputs a post recipe may request from an accumulated window.
2893POST_FIELD_STATISTICS_OUTPUTS = ("mean", "reynolds_stress", "rms", "tke", "flux")
2894
2895#: Output formats a post recipe may request. `vtk` writes the derived fields; `csv`
2896#: appends one convergence row per processed step.
2897POST_FIELD_STATISTICS_FORMATS = ("vtk", "csv")
2898
2899# ---------------------------------------------------------------------------
2900# Authoritative public choice sets.
2901#
2902# Each of these was a literal written inline at its point of use, which made it
2903# invisible to the capability census: a set nobody can enumerate is a set nobody can
2904# document. Naming them here is the discovery contract - the census enumerates
2905# module-level names with these suffixes and demands a classification for each.
2906# ---------------------------------------------------------------------------
2907
2908#: How the grid reaches the solver.
2909GRID_MODES = ("file", "programmatic_c", "grid_gen")
2910
2911#: Geometries the bundled grid generator can produce.
2912GRID_GENERATOR_TYPES = ("box", "sweep")
2913
2914#: Cross-sections a swept duct may carry. `circle` is the square-to-disc map, not an
2915#: O-grid: `Metric.c`'s `cgrid` branch for a circumferential seam is reachable only via
2916#: `programmatic_c`'s `cgrids` flag, and even there `src/grid.c` never builds anything
2917#: but a Cartesian box, so no path produces real O-grid coordinates to seam.
2918GRID_CROSS_SECTION_KINDS = ("rectangle", "circle")
2919
2920#: Segment kinds a piecewise wall-height field accepts. A wall is built from these end to
2921#: end, so a step is one entry in a list rather than a geometry of its own.
2922GRID_WALL_SEGMENT_KINDS = ("flat", "step", "ramp", "arc", "sine", "gaussian", "hill")
2923
2924#: Centreline segment kinds a swept path accepts.
2925GRID_PATH_SEGMENT_KINDS = ("straight", "arc")
2926
2927#: Placement and similarity operations applied after a geometry map.
2928GRID_TRANSFORM_KINDS = ("anchor", "translate", "scale", "rotate", "mirror", "permute")
2929
2930#: Whether a run seeds particles afresh or restores them from a checkpoint.
2931PARTICLE_RESTART_MODES = ("init", "load")
2932
2933#: Eulerian post-processing kernels selectable per pipeline entry.
2934POST_EULERIAN_PIPELINE_TASKS = ("q_criterion", "normalize_field", "nodal_average")
2935
2936#: Lagrangian post-processing kernels selectable per pipeline entry.
2937POST_LAGRANGIAN_PIPELINE_TASKS = ("specific_ke",)
2938
2939#: Study shapes `picurv sweep` knows how to expand and aggregate.
2940STUDY_TYPES = ("grid_independence", "timestep_independence", "sensitivity")
2941
2942#: Preconditioning models for the Newton-Krylov momentum solve.
2943NEWTON_KRYLOV_PRECONDITIONER_MODELS = ("none", "frozen_momentum_jacobian")
2944
2945#: Matrix structures a Newton-Krylov preconditioner model may assemble. Determined by
2946#: the model rather than chosen independently: `frozen_momentum_jacobian` requires
2947#: `point_block`, and `none` accepts no structure at all.
2948NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES = ("none", "point_block")
2949
2950#: Outer preconditioners the Poisson solve accepts. One canonical value today; the
2951#: census fails if this grows without a capability family being registered for it.
2952POISSON_PRECONDITIONER_TYPES = ("multigrid",)
2953
2954#: Accepted spellings for the Poisson outer preconditioner.
2955POISSON_PRECONDITIONER_SPELLINGS = {"mg": "multigrid", "pcmg": "multigrid"}
2956
2957# Storage compression policies live in picurv_cli/storage.py, which owns them; core
2958# imports storage, so declaring them here would be a cycle.
2959
2960#: How a prescribed Eulerian field is supplied.
2961PRESCRIBED_FLOW_SOURCE_TYPES = ("file", "generated", "field_slice")
2962
2963#: Analytic scalar profiles the verification source may generate.
2964VERIFICATION_SCALAR_PROFILES = ("CONSTANT", "LINEAR_X", "SIN_PRODUCT")
2965
2966#: Image formats `picurv sweep` can render study plots in.
2967STUDY_PLOT_FORMATS = ("png", "pdf", "svg")
2968
2969#: How much per-timestep profiling output the monitor emits.
2970PROFILING_TIMESTEP_MODES = ("off", "selected", "all")
2971
2972#: Krylov methods for which a `gmres.restart` parameter is meaningful. This does NOT
2973#: restrict `poisson_solver.method`, which passes any PETSc KSP token through.
2974GMRES_RESTART_METHODS = ("gmres", "fgmres", "lgmres")
2975
2976#: Analytical solution types the Eulerian source can impose.
2977ANALYTICAL_SOLUTION_TYPES = ("TGV3D", "ZERO_FLOW", "UNIFORM_FLOW")
2978
2979#: Legacy capitalised spellings of the field-initialisation mode, still accepted and
2980#: resolved through normalize_field_init_mode.
2981LEGACY_FIELD_INIT_SPELLINGS = ("Zero", "Constant", "Poiseuille")
2982
2983#: Discrete operators the solenoidal projection may use.
2984PROJECTION_OPERATORS = ("continuum", "picurv_discrete")
2985
2986#: Where `picurv sweep` reads a declared metric from.
2987METRIC_SOURCE_KINDS = ("statistics_csv", "csv", "log_regex", "log")
2988
2989#: Where a run is launched.
2990LAUNCH_MODES = ("local", "cluster")
2991
2992
2993
2994def normalize_post_spectra_config(post_cfg: dict) -> dict:
2995 """!
2996 @brief Validate and canonicalize the spectra block of post.yml.
2997
2998 @details The recipe chooses what to measure; whether the case *can* support that
2999 measurement is a separate question answered by
3000 `validate_post_spectra_preconditions()`, which needs the grid and the
3001 boundary conditions. Keeping the two apart means a recipe stays valid on
3002 its own terms even when it is read without a case beside it.
3003
3004 @param[in] post_cfg Parsed post-processing configuration.
3005 @return Normalized mapping with an output prefix and a list of tasks.
3006 @throws ValueError on a malformed or inconsistent recipe.
3007 """
3008 raw = (post_cfg or {}).get("spectra")
3009 if raw is None:
3010 return {"output_prefix": "Spectrum", "tasks": []}
3011 if not isinstance(raw, dict):
3012 raise ValueError("'spectra' must be a mapping.")
3013
3014 prefix = raw.get("output_prefix", "Spectrum")
3015 if not isinstance(prefix, str) or not prefix.strip():
3016 raise ValueError("'spectra.output_prefix' must be a non-empty string.")
3017
3018 tasks = raw.get("tasks")
3019 if not isinstance(tasks, list) or not tasks:
3020 raise ValueError("'spectra.tasks' must be a non-empty list.")
3021
3022 cleaned = []
3023 seen = []
3024 for position, entry in enumerate(tasks):
3025 if not isinstance(entry, dict):
3026 raise ValueError(f"spectra task {position}: each task must be a mapping.")
3027 name = entry.get("task")
3028 if name not in POST_SPECTRA_TASKS:
3029 raise ValueError(
3030 f"spectra task {position}: unknown task {name!r}. "
3031 f"Available tasks: {sorted(POST_SPECTRA_TASKS)}."
3032 )
3033 spec = POST_SPECTRA_TASKS[name]
3034
3035 field = entry.get("field", spec["fields"][0])
3036 if field not in spec["fields"]:
3037 raise ValueError(
3038 f"spectra task '{name}': field {field!r} is not supported. "
3039 f"Supported fields: {list(spec['fields'])}."
3040 )
3041
3042 block = entry.get("block", 0)
3043 if not isinstance(block, int) or isinstance(block, bool) or block < 0:
3044 raise ValueError(f"spectra task '{name}': 'block' must be a non-negative integer.")
3045
3046 symbol = entry.get("symbol", "continuum")
3047 if symbol not in POST_SPECTRA_SYMBOLS:
3048 raise ValueError(
3049 f"spectra task '{name}': unknown symbol {symbol!r}. "
3050 f"Available symbols: {list(POST_SPECTRA_SYMBOLS)}."
3051 )
3052
3053 subtract_mean = entry.get("subtract_mean", "none")
3054 if not isinstance(subtract_mean, str) or not subtract_mean.strip():
3055 raise ValueError(f"spectra task '{name}': 'subtract_mean' must be a string.")
3056 subtract_mean = subtract_mean.strip()
3057 if subtract_mean.startswith("window:"):
3058 window_name = subtract_mean.split(":", 1)[1].strip()
3059 if not window_name:
3060 raise ValueError(
3061 f"spectra task '{name}': 'subtract_mean: window:<name>' needs a window name."
3062 )
3063 elif subtract_mean not in POST_SPECTRA_MEAN_MODES:
3064 raise ValueError(
3065 f"spectra task '{name}': unknown subtract_mean {subtract_mean!r}. "
3066 f"Use one of {list(POST_SPECTRA_MEAN_MODES)} or 'window:<name>'."
3067 )
3068
3069 # A window mean is only worth subtracting once the window has converged, so the
3070 # bundle the mean is read from is chosen separately from the step being
3071 # transformed. Left unset, each step uses its own bundle, whose running mean is
3072 # undefined at the moment the window activates.
3073 mean_source_step = entry.get("mean_source_step")
3074 if mean_source_step is not None:
3075 if (not isinstance(mean_source_step, int) or isinstance(mean_source_step, bool)
3076 or mean_source_step < 0):
3077 raise ValueError(
3078 f"spectra task '{name}': 'mean_source_step' must be a non-negative integer."
3079 )
3080 if not subtract_mean.startswith("window:"):
3081 raise ValueError(
3082 f"spectra task '{name}': 'mean_source_step' only applies to "
3083 f"'subtract_mean: window:<name>'."
3084 )
3085
3086 reference = entry.get("reference")
3087 if reference is not None and (not isinstance(reference, str) or not reference.strip()):
3088 raise ValueError(f"spectra task '{name}': 'reference' must be a non-empty string.")
3089
3090 # Each task writes its own file, so a repeat would overwrite its own output.
3091 identity = (name, field, block, symbol)
3092 if identity in seen:
3093 raise ValueError(
3094 f"spectra recipe lists task '{name}' for field {field} on block {block} "
3095 f"with symbol '{symbol}' more than once."
3096 )
3097 seen.append(identity)
3098 cleaned.append({
3099 "task": name,
3100 "field": field,
3101 "block": block,
3102 "symbol": symbol,
3103 "subtract_mean": subtract_mean,
3104 "mean_source_step": mean_source_step,
3105 "reference": reference.strip() if isinstance(reference, str) else None,
3106 })
3107
3108 return {"output_prefix": prefix.strip(), "tasks": cleaned}
3109
3110
3111def validate_post_spectra_preconditions(spectra_cfg: dict, case_cfg: dict, post_path: str) -> list:
3112 """!
3113 @brief Check spectra tasks against what the case can actually support.
3114
3115 @details Runs before any field is read, so a case with no homogeneous direction is
3116 refused rather than yielding a curve that means nothing. Only the checks
3117 the case file can answer are made here: periodicity and block count. Grid
3118 uniformity is enforced by `generators/spectra.gen`, which reads the staged
3119 PICGRID and is the only place the real node coordinates are known.
3120
3121 @param[in] spectra_cfg Normalized spectra recipe.
3122 @param[in] case_cfg Parsed case configuration.
3123 @param[in] post_path Post recipe path, for error messages.
3124 @return List of formatted error strings; empty when every task is supportable.
3125 """
3126 errors = []
3127 if not spectra_cfg.get("tasks"):
3128 return errors
3129
3130 try:
3131 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
3132 except ValueError:
3133 # The case file reports its own boundary-condition errors; do not repeat them.
3134 return errors
3135
3136 block_count = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
3137 periodic_faces = {}
3138 for block_index, block_bcs in enumerate(prepared_blocks):
3139 faces = {entry.get("face") for entry in block_bcs if entry.get("type") == "PERIODIC"}
3140 periodic_faces[block_index] = faces
3141
3142 all_faces = {"-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta"}
3143 for task_cfg in spectra_cfg["tasks"]:
3144 spec = POST_SPECTRA_TASKS[task_cfg["task"]]
3145 label = f"spectra task '{task_cfg['task']}'"
3146
3147 if spec.get("requires_single_block") and block_count != 1:
3148 errors.append(
3149 f" {post_path}: {label} requires a single-block domain; this case has "
3150 f"{block_count} blocks."
3151 )
3152
3153 block = task_cfg["block"]
3154 if block >= block_count:
3155 errors.append(
3156 f" {post_path}: {label} targets block {block}, but the case defines "
3157 f"{block_count} block(s)."
3158 )
3159 continue
3160
3161 if spec.get("requires_periodic_geometric"):
3162 missing = sorted(all_faces - periodic_faces.get(block, set()))
3163 if missing:
3164 errors.append(
3165 f" {post_path}: {label} requires every face of block {block} to be "
3166 f"PERIODIC, because a shell-averaged spectrum is only defined for a "
3167 f"triply periodic homogeneous box. Non-periodic faces: {missing}."
3168 )
3169 return errors
3170
3171
3172def _resolve_spectra_payload(bundle: dict, kind: str, field: str, block: int) -> str:
3173 """!
3174 @brief Locate one checkpoint payload by its inventory entry rather than by path shape.
3175 @param[in] bundle Validated checkpoint bundle mapping.
3176 @param[in] kind Payload kind recorded in the inventory.
3177 @param[in] field Payload field name recorded in the inventory.
3178 @param[in] block Block index the payload belongs to.
3179 @return Absolute path to the payload file.
3180 @throws ValueError when the bundle carries no such payload.
3181 """
3182 for payload in bundle["payloads"]:
3183 if (payload["kind"] == kind and payload["field"] == field
3184 and payload["block"] == str(block)):
3185 return os.path.join(bundle["bundle"], *payload["path"].split("/"))
3186 raise ValueError(
3187 f"checkpoint {os.path.basename(bundle['bundle'])} carries no {kind} payload "
3188 f"'{field}' for block {block}."
3189 )
3190
3191
3192def _spectra_mean_arguments(task_cfg: dict, bundle: dict, mean_bundle: dict = None) -> list:
3193 """!
3194 @brief Build the generator arguments implementing a task's fluctuation choice.
3195 @param[in] task_cfg Normalized spectra task.
3196 @param[in] bundle Validated checkpoint bundle for the step being transformed.
3197 @param[in] mean_bundle Bundle supplying the window mean; defaults to @p bundle.
3198 @return Argument list to append to the generator command.
3199 @throws ValueError when a named window is absent from the bundle.
3200 """
3201 mode = task_cfg["subtract_mean"]
3202 if not mode.startswith("window:"):
3203 return ["--subtract-mean", mode]
3204 window = mode.split(":", 1)[1].strip()
3205 block = task_cfg["block"]
3206 source = mean_bundle or bundle
3207 mean_path = _resolve_spectra_payload(source, "mean", f"{window}/{task_cfg['field']}_mean", block)
3208 count_path = _resolve_spectra_payload(source, "occupancy", f"{window}/count", block)
3209 return ["--subtract-mean", "field", "--mean-file", mean_path, "--count-file", count_path]
3210
3211
3212#: Scalar columns the spectra stage records once per processed step.
3213POST_SPECTRA_SCALAR_COLUMNS = (
3214 "resolved_kinetic_energy", "spectrum_total_energy", "parseval_residual",
3215 "spectrum_peak_k", "zero_mode_energy", "integral_length_scale",
3216 "taylor_microscale", "dissipation_over_viscosity",
3217)
3218
3219
3220#: Stages `picurv run --post-process` can execute, in the order they are listed.
3221POST_STAGE_NAMES = ("fields", "spectra")
3222
3223
3225 """!
3226 @brief Resolve the `--only` selector into the set of post stages to execute.
3227 @param[in] only Comma-separated selector text, or None for every stage.
3228 @return Set of stage names.
3229 @throws ValueError when the selector names an unknown or empty stage.
3230 """
3231 if not only:
3232 return set(POST_STAGE_NAMES)
3233 requested = [token.strip() for token in str(only).split(",")]
3234 if not all(requested):
3235 raise ValueError("--only must not contain an empty stage name.")
3236 unknown = sorted({token for token in requested if token not in POST_STAGE_NAMES})
3237 if unknown:
3238 raise ValueError(
3239 f"--only names unknown post stage(s) {unknown}. "
3240 f"Available stages: {list(POST_STAGE_NAMES)}."
3241 )
3242 return set(requested)
3243
3244
3245def run_post_spectra_stage(run_dir: str, post_cfg: dict, monitor_cfg: dict,
3246 source_dir: str, steps, quiet: bool = False) -> dict:
3247 """!
3248 @brief Measure spectra for every requested task across a window of committed steps.
3249
3250 @details One spectrum per step per task, because a spectrum is a property of one
3251 state: averaging across steps would be wrong for a decaying flow, where
3252 every step is a different statistical state. Results are written in long
3253 form so a family of curves stays one file, alongside a scalar history that
3254 plots through the ordinary series machinery.
3255
3256 @param[in] run_dir Run directory receiving the output.
3257 @param[in] post_cfg Parsed post-processing configuration.
3258 @param[in] monitor_cfg Parsed monitor configuration anchoring the output root.
3259 @param[in] source_dir Directory holding the committed checkpoints.
3260 @param[in] steps Iterable of checkpoint steps to process, in order.
3261 @param[in] quiet Suppress progress reporting.
3262 @return Summary with the written paths and the steps actually processed.
3263 @throws ValueError when the generator fails or a requested payload is absent.
3264 """
3265 spectra = normalize_post_spectra_config(post_cfg)
3266 if not spectra["tasks"]:
3267 return {"tasks": [], "steps": [], "artifacts": []}
3268
3269 script = os.path.join(GENERATORS_PATH, "spectra.gen")
3270 if not os.path.isfile(script):
3271 raise ValueError(f"spectra.gen script not found: {script}")
3272 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
3273 if not os.path.isfile(staged_grid):
3274 raise ValueError(f"spectra require a staged PICGRID at {staged_grid}.")
3275
3276 output_dir = os.path.join(
3277 os.path.abspath(run_dir), resolve_recipe_spectra_output_dir(post_cfg, monitor_cfg)
3278 )
3279 os.makedirs(output_dir, exist_ok=True)
3280
3281 # Spectra are computed from the staged non-dimensional field on the staged
3282 # non-dimensional grid, so physical units are the generator's to apply. The run's
3283 # own configuration snapshot is the authority for the scales it used.
3284 scale_arguments = []
3285 if bool((post_cfg.get("global_operations") or {}).get("dimensionalize", False)):
3286 active_case = load_active_run_configuration(run_dir).get("case")
3287 case_path = os.path.join(run_dir, active_case) if active_case else None
3288 if case_path and os.path.isfile(case_path):
3289 scaling = resolve_fluid_scaling(read_yaml_file(case_path))
3290 scale_arguments = [
3291 "--velocity-ref", repr(float(scaling["velocity_ref"])),
3292 "--length-ref", repr(float(scaling["length_ref"])),
3293 ]
3294 elif not quiet:
3295 print("[WARNING] Spectra: dimensionalize was requested but this run carries no "
3296 "readable case snapshot; results stay non-dimensional.", file=sys.stderr)
3297
3298 requested = sorted(set(int(step) for step in steps))
3299 # The requested window is what the recipe asks for, not what exists. The field
3300 # post-processor is bounded by the available source frontier and this must be too:
3301 # a window reaching past the last committed checkpoint is normal while a solve is
3302 # still running, and is not an error.
3303 available = _scan_committed_checkpoint_steps(source_dir)
3304 ordered_steps = [step for step in requested if step in available]
3305 if not ordered_steps:
3306 if not quiet:
3307 print("[INFO] Spectra: no committed checkpoint in the requested window yet; "
3308 "nothing measured.")
3309 return {"tasks": [], "steps": [], "artifacts": []}
3310 if not quiet and len(ordered_steps) != len(requested):
3311 print(f"[INFO] Spectra: {len(ordered_steps)} of {len(requested)} requested step(s) "
3312 f"are committed; measuring those.")
3313 artifacts = []
3314 for task_cfg in spectra["tasks"]:
3315 basename = post_spectra_task_basename(task_cfg, spectra["output_prefix"])
3316 spectrum_path = os.path.join(output_dir, f"{basename}.csv")
3317 scalar_path = os.path.join(output_dir, f"{basename}_history.csv")
3318 spectrum_rows = []
3319 scalar_rows = []
3320 mean_bundle = None
3321 if task_cfg["mean_source_step"] is not None:
3322 mean_bundle = validate_committed_checkpoint(source_dir, task_cfg["mean_source_step"])
3323
3324 for step in ordered_steps:
3325 bundle = validate_committed_checkpoint(source_dir, step)
3326 time = float(bundle["metadata"]["checkpoint_time"])
3327 field_path = _resolve_spectra_payload(
3328 bundle, "eulerian", task_cfg["field"], task_cfg["block"]
3329 )
3330 cmd = [sys.executable, script, "shell-spectrum",
3331 "--field-file", field_path, "--source-grid", staged_grid,
3332 "--block", str(task_cfg["block"]), "--symbol", task_cfg["symbol"]]
3333 cmd.extend(_spectra_mean_arguments(task_cfg, bundle, mean_bundle))
3334 cmd.extend(scale_arguments)
3335 result = subprocess.run(cmd, text=True, capture_output=True)
3336 if result.returncode != 0:
3337 details = (result.stderr or result.stdout or "").strip()
3338 raise ValueError(
3339 f"spectra task '{task_cfg['task']}' failed at step {step} with exit code "
3340 f"{result.returncode}. Details:\n{details}"
3341 )
3342 summary = json.loads(result.stdout)
3343 for row in summary["shell_spectrum"]:
3344 spectrum_rows.append({"step": step, "time": time,
3345 "k": row["k"], "energy": row["energy"]})
3346 scalar_rows.append({"step": step, "time": time,
3347 **{name: summary[name] for name in POST_SPECTRA_SCALAR_COLUMNS}})
3348
3349 with open(spectrum_path, "w", newline="", encoding="utf-8") as stream:
3350 writer = csv.DictWriter(stream, fieldnames=("step", "time", "k", "energy"))
3351 writer.writeheader()
3352 writer.writerows(spectrum_rows)
3353 with open(scalar_path, "w", newline="", encoding="utf-8") as stream:
3354 writer = csv.DictWriter(stream, fieldnames=("step", "time") + POST_SPECTRA_SCALAR_COLUMNS)
3355 writer.writeheader()
3356 writer.writerows(scalar_rows)
3357 artifacts.extend([spectrum_path, scalar_path])
3358 if not quiet:
3359 print(f"[INFO] Spectra: wrote {len(scalar_rows)} step(s) for task "
3360 f"'{task_cfg['task']}' to {os.path.relpath(spectrum_path, run_dir)}")
3361
3362 return {
3363 "tasks": [entry["task"] for entry in spectra["tasks"]],
3364 "steps": ordered_steps,
3365 "artifacts": artifacts,
3366 }
3367
3368
3369def compute_post_spectra_signature(spectra_cfg: dict) -> str:
3370 """!
3371 @brief Reduce a normalized spectra recipe to a stable identity string.
3372 @param[in] spectra_cfg Normalized spectra recipe.
3373 @return Hex digest covering every choice that changes the produced spectra.
3374 """
3375 payload = json.dumps(spectra_cfg, sort_keys=True, separators=(",", ":")).encode("utf-8")
3376 return hashlib.sha256(payload).hexdigest()[:16]
3377
3378
3379def resolve_post_spectra_output_dir(monitor_cfg=None) -> str:
3380 """!
3381 @brief Resolve the run-relative directory spectra CSVs are written to.
3382 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
3383 @return Run-relative directory path.
3384 """
3385 del monitor_cfg
3386 return CANONICAL_RUN_PATHS["spectra"]
3387
3388
3389def resolve_recipe_spectra_output_dir(post_cfg: dict, monitor_cfg=None) -> str:
3390 """!
3391 @brief Resolve the canonical spectra directory for one versioned recipe.
3392 @param[in] post_cfg Parsed runtime post config.
3393 @param[in] monitor_cfg Optional monitor config for standalone compatibility.
3394 @return Run-relative spectra directory.
3395 """
3396 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
3397 return internal.get("spectra") or resolve_post_spectra_output_dir(monitor_cfg)
3398
3399
3400def get_post_spectra_output_artifacts(post_cfg: dict, run_dir: str, monitor_cfg=None) -> list:
3401 """!
3402 @brief Predict the spectra CSV paths a recipe will write.
3403 @param[in] post_cfg Parsed post-processing configuration.
3404 @param[in] run_dir Run directory the recipe operates on.
3405 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
3406 @return Absolute CSV paths, one per task, in recipe order.
3407 """
3408 try:
3409 spectra = normalize_post_spectra_config(post_cfg)
3410 except ValueError:
3411 # The recipe reports its own errors during validation; predict nothing here.
3412 return []
3413 if not spectra["tasks"]:
3414 return []
3415 base = os.path.join(os.path.abspath(run_dir), resolve_recipe_spectra_output_dir(post_cfg, monitor_cfg))
3416 paths = []
3417 for task_cfg in spectra["tasks"]:
3418 name = post_spectra_task_basename(task_cfg, spectra["output_prefix"])
3419 paths.append(os.path.join(base, f"{name}.csv"))
3420 return list(dict.fromkeys(paths))
3421
3422
3423def post_spectra_task_basename(task_cfg: dict, output_prefix: str) -> str:
3424 """!
3425 @brief Build the file basename one normalized spectra task writes.
3426 @param[in] task_cfg Normalized task mapping.
3427 @param[in] output_prefix Recipe output prefix.
3428 @return Basename without directory or extension.
3429 """
3430 parts = [output_prefix, task_cfg["task"], task_cfg["field"],
3431 f"block{task_cfg['block']:04d}", task_cfg["symbol"]]
3432 return "_".join(parts)
3433
3434
3435def normalize_post_field_statistics_config(post_cfg: dict) -> dict:
3436 """!
3437 @brief Validate and canonicalize the field_statistics block of post.yml.
3438
3439 @details The recipe names windows rather than redescribing them, because the
3440 window definitions already reach the post-processor through the run's
3441 solver control. Validation therefore covers the recipe's own choices
3442 only; a name no window matches is caught in C against the resolved list,
3443 which is the only place the real set is known.
3444
3445 @param[in] post_cfg Parsed post-processing configuration.
3446 @return Value returned by `normalize_post_field_statistics_config()`.
3447 """
3448 raw = (post_cfg or {}).get("field_statistics")
3449 if raw is None:
3450 return {"windows": [], "source_step": None, "outputs": [], "formats": []}
3451 if not isinstance(raw, dict):
3452 raise ValueError("'field_statistics' must be a mapping.")
3453
3454 windows = raw.get("windows")
3455 if not isinstance(windows, list) or not windows:
3456 raise ValueError("'field_statistics.windows' must be a non-empty list of window names.")
3457 cleaned = []
3458 for entry in windows:
3459 if not isinstance(entry, str) or not entry.strip():
3460 raise ValueError("'field_statistics.windows' entries must be non-empty window names.")
3461 name = entry.strip()
3462 # Each window writes its own file, so a repeat would overwrite its own output.
3463 if name in cleaned:
3464 raise ValueError(f"field statistics recipe lists window '{name}' more than once.")
3465 cleaned.append(name)
3466
3467 label = ", ".join(cleaned)
3468 source_step = raw.get("source_step")
3469 if source_step is not None:
3470 if not isinstance(source_step, int) or isinstance(source_step, bool) or source_step < 0:
3471 raise ValueError(
3472 f"field statistics recipe for [{label}]: 'source_step' must be a non-negative "
3473 f"integer (got {source_step!r})."
3474 )
3475
3476 outputs = raw.get("outputs", list(POST_FIELD_STATISTICS_OUTPUTS))
3477 if not isinstance(outputs, list) or not outputs:
3478 raise ValueError(f"field statistics recipe for [{label}]: 'outputs' must be a non-empty list.")
3479 unknown = [item for item in outputs if item not in POST_FIELD_STATISTICS_OUTPUTS]
3480 if unknown:
3481 raise ValueError(
3482 f"field statistics recipe for [{label}]: unknown outputs {unknown}. "
3483 f"Available outputs: {list(POST_FIELD_STATISTICS_OUTPUTS)}."
3484 )
3485 if len(set(outputs)) != len(outputs):
3486 raise ValueError(f"field statistics recipe for [{label}]: 'outputs' lists a duplicate.")
3487
3488 formats = raw.get("formats", ["vtk"])
3489 if not isinstance(formats, list) or not formats:
3490 raise ValueError(f"field statistics recipe for [{label}]: 'formats' must be a non-empty list.")
3491 unknown = [item for item in formats if item not in POST_FIELD_STATISTICS_FORMATS]
3492 if unknown:
3493 raise ValueError(
3494 f"field statistics recipe for [{label}]: unknown formats {unknown}. "
3495 f"Available formats: {list(POST_FIELD_STATISTICS_FORMATS)}."
3496 )
3497 if len(set(formats)) != len(formats):
3498 raise ValueError(f"field statistics recipe for [{label}]: 'formats' lists a duplicate.")
3499
3500 return {
3501 "windows": cleaned,
3502 "source_step": source_step,
3503 "outputs": list(outputs),
3504 "formats": list(formats),
3505 }
3506
3507
3508def _post_window_derived_field_count(window_cfg: dict, outputs: list) -> int:
3509 """!
3510 @brief Count the derived fields one window would produce for a set of outputs.
3511
3512 @details Mirrors the resolution the C derivation performs, so a recipe that would
3513 produce an empty file is refused before the run rather than after it. Each
3514 output resolves against what the window accumulated: a window keeping only
3515 first moments has no stresses, RMS, or turbulent kinetic energy to give.
3516
3517 @param[in] window_cfg Normalized window definition from monitor.yml.
3518 @param[in] outputs Requested output kinds.
3519 @return Value returned by `_post_window_derived_field_count()`.
3520 """
3521 fields = window_cfg.get("fields", []) or []
3522 covariances = window_cfg.get("covariances", []) or []
3523 with_second = [f for f in fields if "second" in (f.get("moments") or [])]
3524 total = 0
3525 for kind in outputs:
3526 if kind == "mean":
3527 total += len(fields)
3528 elif kind == "reynolds_stress":
3529 for entry in with_second:
3530 dof = STATISTICS_ELIGIBLE_FIELDS[entry["field"]]["components"]
3531 total += 6 if dof == 3 else 1
3532 elif kind == "rms":
3533 for entry in with_second:
3534 total += STATISTICS_ELIGIBLE_FIELDS[entry["field"]]["components"]
3535 elif kind == "tke":
3536 total += sum(1 for entry in with_second
3537 if STATISTICS_ELIGIBLE_FIELDS[entry["field"]]["components"] == 3)
3538 elif kind == "flux":
3539 total += len(covariances)
3540 return total
3541
3542
3543def _post_requests_field_statistics(post_cfg: dict) -> bool:
3544 """!
3545 @brief Return whether the current post recipe derives accumulated field statistics.
3546 @param[in] post_cfg Argument passed to `_post_requests_field_statistics()`.
3547 @return Value returned by `_post_requests_field_statistics()`.
3548 """
3549 try:
3550 return bool(normalize_post_field_statistics_config(post_cfg)["windows"])
3551 except ValueError:
3552 # Malformed configuration is reported by validation, not by this predicate.
3553 return False
3554
3555
3556def get_post_field_statistics_artifacts(post_cfg: dict, run_dir: str):
3557 """!
3558 @brief Predict the per-window statistics artifacts a recipe will produce.
3559 @details Returns one entry per window and format, so resume tracking can tell a
3560 half-finished window from a completed one.
3561 @param[in] post_cfg Parsed post-processing configuration.
3562 @param[in] run_dir Run directory the outputs are written under.
3563 @return List of (kind, path_prefix) tuples; kind is 'vtk' or 'csv'.
3564 """
3566 if not config["windows"]:
3567 return []
3568 io_cfg = post_cfg.get("io", {}) or {}
3569 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
3570 visualization_dir = _post_output_directory_abs(run_dir, post_cfg)
3571 visualization_prefix = io_cfg.get("output_filename_prefix", "Field")
3572 csv_prefix_path = None
3573 if internal.get("field_statistics_prefix"):
3574 csv_prefix_path = os.path.join(run_dir, internal["field_statistics_prefix"])
3575 artifacts = []
3576 for window in config["windows"]:
3577 if "vtk" in config["formats"]:
3578 artifacts.append(("vtk", os.path.join(
3579 visualization_dir, f"{visualization_prefix}_statistics_{window}"
3580 )))
3581 if "csv" in config["formats"]:
3582 csv_base = csv_prefix_path or os.path.join(
3583 visualization_dir, str(visualization_prefix)
3584 )
3585 artifacts.append(("csv", f"{csv_base}_statistics_{window}.csv"))
3586 return artifacts
3587
3588
3589def build_post_recipe_config(post_cfg: dict, monitor_cfg=None) -> dict:
3590 """!
3591 @brief Build the flat key=value mapping consumed by the C post-processor.
3592 @param[in] post_cfg Argument passed to `build_post_recipe_config()`.
3593 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
3594 @return Value returned by `build_post_recipe_config()`.
3595 """
3596 c_config = {}
3597
3598 c_config['startTime'] = get_post_run_control_value(post_cfg, 'start_step', 0)
3599 c_config['endTime'] = get_post_run_control_value(post_cfg, 'end_step', 0)
3600 c_config['timeStep'] = get_post_run_control_value(post_cfg, 'step_interval', 1)
3601
3602 eulerian_pipeline_parts = []
3603 dimensionalize = bool(post_cfg.get('global_operations', {}).get('dimensionalize', False))
3604 if dimensionalize:
3605 eulerian_pipeline_parts.append('DimensionalizeAllLoadedFields')
3606 # The field pipeline is one of three producers. Derived statistics scale in the
3607 # accumulator and spectra scale in their generator, so the request has to reach
3608 # them as a setting rather than as a pipeline stage.
3609 c_config['dimensionalize'] = 'true'
3610
3611 for task in post_cfg.get('eulerian_pipeline', []):
3612 task_name = task.get('task')
3613 if task_name == 'q_criterion':
3614 eulerian_pipeline_parts.append('ComputeQCriterion')
3615 elif task_name == 'normalize_field':
3616 field = task.get('field', 'P')
3617 eulerian_pipeline_parts.append(f'NormalizeRelativeField:{field}')
3618 ref_point = task.get('reference_point', [1, 1, 1])
3619 c_config['reference_ip'] = ref_point[0]
3620 c_config['reference_jp'] = ref_point[1]
3621 c_config['reference_kp'] = ref_point[2]
3622 elif task_name == 'nodal_average':
3623 in_field = task.get('input_field')
3624 out_field = task.get('output_field')
3625 if in_field and out_field:
3626 eulerian_pipeline_parts.append(f'CellToNodeAverage:{in_field}>{out_field}')
3627
3628 if eulerian_pipeline_parts:
3629 c_config['process_pipeline'] = ";".join(eulerian_pipeline_parts)
3630
3631 lagrangian_pipeline_parts = []
3632 for task in post_cfg.get('lagrangian_pipeline', []):
3633 task_name = task.get('task')
3634 if task_name == 'specific_ke':
3635 in_field = task.get('input_field')
3636 out_field = task.get('output_field')
3637 if in_field and out_field:
3638 lagrangian_pipeline_parts.append(f'ComputeSpecificKE:{in_field}>{out_field}')
3639 if lagrangian_pipeline_parts:
3640 c_config['particle_pipeline'] = ";".join(lagrangian_pipeline_parts)
3641
3642 statistics_pipeline_parts = get_post_statistics_task_tokens(post_cfg)
3643 statistics_output_prefix = None
3644 stats_cfg = post_cfg.get('statistics_pipeline')
3645 if isinstance(stats_cfg, dict):
3646 statistics_output_prefix = stats_cfg.get('output_prefix')
3647
3648 if statistics_pipeline_parts:
3649 c_config['statistics_pipeline'] = ";".join(statistics_pipeline_parts)
3650 statistics_output_prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
3651 elif statistics_output_prefix is None:
3652 statistics_output_prefix = post_cfg.get('statistics_output_prefix')
3653 if statistics_output_prefix:
3654 c_config['statistics_output_prefix'] = statistics_output_prefix
3655
3656 io = post_cfg.get('io', {})
3657 internal_paths = post_cfg.get('_picurv_paths', {}) or {}
3658 field_statistics = normalize_post_field_statistics_config(post_cfg)
3659 if field_statistics["windows"]:
3660 c_config['field_statistics_windows'] = ",".join(field_statistics["windows"])
3661 c_config['field_statistics_outputs'] = ",".join(field_statistics["outputs"])
3662 c_config['field_statistics_formats'] = ",".join(field_statistics["formats"])
3663 # An omitted source step means each processed step derives from its own
3664 # bundle, which is what turns a multi-step recipe into a convergence history.
3665 if field_statistics["source_step"] is not None:
3666 c_config['field_statistics_source_step'] = field_statistics["source_step"]
3667 if internal_paths.get("field_statistics_prefix"):
3668 c_config['field_statistics_output_prefix'] = internal_paths["field_statistics_prefix"]
3669
3670 c_config['output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('output_filename_prefix', 'Field')
3671 c_config['particle_output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('particle_filename_prefix', 'Particle')
3672 c_config['output_particles'] = io.get('output_particles', False)
3673 c_config['particle_output_freq'] = io.get('particle_subsampling_frequency', 1)
3674 c_config['output_fields_instantaneous'] = ",".join(io.get('eulerian_fields', []))
3675 c_config['particle_fields_instantaneous'] = ",".join(io.get('particle_fields', []))
3676 input_extensions = get_post_input_extensions(post_cfg)
3677 if isinstance(input_extensions, dict):
3678 for extension_name in ('eulerian', 'particle'):
3679 extension = input_extensions.get(extension_name)
3680 if extension and str(extension).strip().lstrip('.').lower() != 'dat':
3681 raise ValueError(
3682 f"post input extension '{extension_name}' must be 'dat'; "
3683 "committed checkpoint payload names are fixed."
3684 )
3685
3686 source_directory = get_post_source_directory_template(post_cfg, default=None)
3687 if source_directory is not None:
3688 c_config['source_directory'] = source_directory
3689
3690 # Spectra run in the conductor's Python stage, but the recipe fingerprint is
3691 # computed over this mapping, so the block has to be represented here or a
3692 # changed spectra recipe would resume against stale lineage. The C
3693 # post-processor accepts and ignores the key.
3694 spectra = normalize_post_spectra_config(post_cfg)
3695 if spectra["tasks"]:
3696 c_config['spectra_signature'] = compute_post_spectra_signature(spectra)
3697
3698 return c_config
3699
3700
3701def compute_post_recipe_id(post_cfg: dict) -> str:
3702 """!
3703 @brief Compute a stable human-readable identity for one post recipe.
3704 @param[in] post_cfg Parsed post configuration before runtime path injection.
3705 @return Filesystem-safe recipe identity.
3706 """
3707 normalized = copy.deepcopy(post_cfg or {})
3708 normalized.pop("_picurv_paths", None)
3709 source = normalized.get("source_data")
3710 if isinstance(source, dict):
3711 source.pop("directory", None)
3712 io = normalized.get("io")
3713 label = "post"
3714 if isinstance(io, dict):
3715 io.pop("output_directory", None)
3716 raw_label = io.get("output_filename_prefix")
3717 if isinstance(raw_label, str) and raw_label.strip():
3718 label = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw_label.strip()).strip("-") or "post"
3719 run_control = normalized.get("run_control")
3720 if isinstance(run_control, dict):
3721 for key in POST_RUN_CONTROL_ALIASES["start_step"] + POST_RUN_CONTROL_ALIASES["end_step"]:
3722 run_control.pop(key, None)
3723 digest = hashlib.sha256(
3724 json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8")
3725 ).hexdigest()[:12]
3726 return f"{label}-{digest}"
3727
3728
3729def apply_canonical_post_paths(post_cfg: dict, run_dir: str) -> "tuple[dict, str]":
3730 """!
3731 @brief Route every post artifact into its fixed analysis or visualization home.
3732 @param[in] post_cfg Parsed user recipe.
3733 @param[in] run_dir Run receiving derived artifacts.
3734 @return Runtime-only config copy and stable recipe id.
3735 """
3736 recipe_id = compute_post_recipe_id(post_cfg)
3737 runtime = copy.deepcopy(post_cfg)
3738 if not isinstance(runtime.get("source_data"), dict):
3739 runtime["source_data"] = {}
3740 runtime["source_data"]["directory"] = os.path.join(
3741 os.path.abspath(run_dir), CANONICAL_RUN_PATHS["output"]
3742 )
3743 io = runtime.setdefault("io", {})
3744 io["output_directory"] = os.path.join(CANONICAL_RUN_PATHS["visualization"], recipe_id)
3745 output_prefix = io.get("output_filename_prefix", "Field")
3746 stats_cfg = runtime.get("statistics_pipeline")
3747 if isinstance(stats_cfg, dict):
3748 basename = stats_cfg.get("output_prefix", "Stats")
3749 stats_cfg["output_prefix"] = os.path.join(
3750 CANONICAL_RUN_PATHS["statistics"], recipe_id, os.path.basename(str(basename))
3751 )
3752 runtime["_picurv_paths"] = {
3753 "recipe_id": recipe_id,
3754 "recipe_root": os.path.join("config", "post-recipes", recipe_id),
3755 "visualization": io["output_directory"],
3756 "statistics": os.path.join(CANONICAL_RUN_PATHS["statistics"], recipe_id),
3757 "field_statistics_prefix": os.path.join(
3758 CANONICAL_RUN_PATHS["statistics"], recipe_id, str(output_prefix)
3759 ),
3760 "spectra": os.path.join(CANONICAL_RUN_PATHS["spectra"], recipe_id),
3761 }
3762 return runtime, recipe_id
3763
3764
3765def normalize_post_recipe_signature(recipe_cfg: dict) -> dict:
3766 """!
3767 @brief Normalize post recipe settings into a stable signature mapping.
3768 @param[in] recipe_cfg Argument passed to `normalize_post_recipe_signature()`.
3769 @return Value returned by `normalize_post_recipe_signature()`.
3770 """
3771 signature = {}
3772 for key, value in (recipe_cfg or {}).items():
3773 if key in POST_RECIPE_SIGNATURE_EXCLUDED_KEYS or value is None:
3774 continue
3775 if isinstance(value, bool):
3776 text = 'true' if value else 'false'
3777 else:
3778 text = str(value).strip()
3779 if text.lower() in {'true', 'false'}:
3780 text = text.lower()
3781 if text:
3782 signature[str(key)] = text
3783 return signature
3784
3785
3786def compute_post_recipe_fingerprint(recipe_cfg: dict) -> "tuple[dict, str]":
3787 """!
3788 @brief Return normalized recipe signature plus SHA-256 fingerprint.
3789 @param[in] recipe_cfg Argument passed to `compute_post_recipe_fingerprint()`.
3790 @return Value returned by `compute_post_recipe_fingerprint()`.
3791 """
3792 signature = normalize_post_recipe_signature(recipe_cfg)
3793 payload = json.dumps(signature, sort_keys=True, separators=(',', ':')).encode('utf-8')
3794 return signature, hashlib.sha256(payload).hexdigest()
3795
3796
3797def parse_post_recipe_file(post_recipe_path: str):
3798 """!
3799 @brief Parse an existing generated post.run file into a key/value mapping.
3800 @param[in] post_recipe_path Argument passed to `parse_post_recipe_file()`.
3801 @return Value returned by `parse_post_recipe_file()`.
3802 """
3803 if not post_recipe_path or not os.path.isfile(post_recipe_path):
3804 return None
3805 recipe_cfg = {}
3806 with open(post_recipe_path, 'r', encoding='utf-8', errors='replace') as f:
3807 for raw_line in f:
3808 line = raw_line.strip()
3809 if not line or line.startswith('#') or '=' not in line:
3810 continue
3811 key, value = line.split('=', 1)
3812 recipe_cfg[key.strip()] = value.strip()
3813 return recipe_cfg
3814
3815
3816def get_post_resume_state_path(run_dir: str, post_cfg: dict = None) -> str:
3817 """!
3818 @brief Return the JSON resume metadata path for a run directory.
3819 @param[in] run_dir Argument passed to `get_post_resume_state_path()`.
3820 @param[in] post_cfg Optional versioned post recipe configuration.
3821 @return Value returned by `get_post_resume_state_path()`.
3822 """
3823 if post_cfg is None:
3824 return os.path.join(run_dir, 'config', POST_RESUME_STATE_FILENAME)
3825 return os.path.join(get_post_recipe_root(run_dir, post_cfg), "state.json")
3826
3827
3828def get_post_recipe_root(run_dir: str, post_cfg: dict) -> str:
3829 """!
3830 @brief Return the versioned run-local control directory for one post recipe.
3831 @param[in] run_dir Owning run root.
3832 @param[in] post_cfg Runtime post config with canonical paths, or a user recipe.
3833 @return Absolute recipe control directory.
3834 """
3835 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
3836 recipe_id = internal.get("recipe_id") or compute_post_recipe_id(post_cfg)
3837 return os.path.join(os.path.abspath(run_dir), "config", "post-recipes", recipe_id)
3838
3839
3840def get_post_lock_paths(run_dir: str, recipe_id: str = None) -> dict:
3841 """!
3842 @brief Return lock-wrapper related paths for a run directory.
3843 @param[in] run_dir Argument passed to `get_post_lock_paths()`.
3844 @param[in] recipe_id Optional stable recipe identity used to scope the lock.
3845 @return Value returned by `get_post_lock_paths()`.
3846 """
3847 scheduler_dir = os.path.join(run_dir, 'scheduler')
3848 suffix = f".{recipe_id}" if recipe_id else ""
3849 return {
3850 'lock_file': os.path.join(scheduler_dir, f"post{suffix}.lock"),
3851 'metadata_file': os.path.join(scheduler_dir, f"post{suffix}.lock.json"),
3852 'wrapper_path': os.path.join(scheduler_dir, POST_LOCK_WRAPPER_FILENAME),
3853 }
3854
3855
3856def _post_output_directory_abs(run_dir: str, post_cfg: dict) -> str:
3857 """!
3858 @brief Resolve the absolute post output directory for the current recipe.
3859 @param[in] run_dir Argument passed to `_post_output_directory_abs()`.
3860 @param[in] post_cfg Argument passed to `_post_output_directory_abs()`.
3861 @return Value returned by `_post_output_directory_abs()`.
3862 """
3863 io_cfg = post_cfg.get('io', {}) or {}
3864 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
3865 relative = internal.get("visualization") or io_cfg.get('output_directory')
3866 if not relative:
3867 relative = os.path.join(CANONICAL_RUN_PATHS["visualization"], compute_post_recipe_id(post_cfg))
3868 return os.path.abspath(os.path.join(run_dir, relative))
3869
3870
3871def _post_requests_eulerian_output(post_cfg: dict) -> bool:
3872 """!
3873 @brief Return whether the current post recipe expects Eulerian VTK output artifacts.
3874 @param[in] post_cfg Argument passed to `_post_requests_eulerian_output()`.
3875 @return Value returned by `_post_requests_eulerian_output()`.
3876 """
3877 io_cfg = post_cfg.get('io', {}) or {}
3878 return bool(io_cfg.get('eulerian_fields'))
3879
3880
3881def _post_requests_particle_output(post_cfg: dict) -> bool:
3882 """!
3883 @brief Return whether the current post recipe expects particle VTP output artifacts.
3884 @param[in] post_cfg Argument passed to `_post_requests_particle_output()`.
3885 @return Value returned by `_post_requests_particle_output()`.
3886 """
3887 io_cfg = post_cfg.get('io', {}) or {}
3888 return bool(io_cfg.get('output_particles')) and bool(io_cfg.get('particle_fields'))
3889
3890
3891def _post_requests_statistics(post_cfg: dict) -> bool:
3892 """!
3893 @brief Return whether the current post recipe expects statistics CSV artifacts.
3894 @param[in] post_cfg Argument passed to `_post_requests_statistics()`.
3895 @return Value returned by `_post_requests_statistics()`.
3896 """
3897 return bool(get_post_statistics_task_tokens(post_cfg))
3898
3899
3900def _post_needs_particle_source(post_cfg: dict) -> bool:
3901 """!
3902 @brief Return whether the current post recipe requires particle source files to be present.
3903 @param[in] post_cfg Argument passed to `_post_needs_particle_source()`.
3904 @return Value returned by `_post_needs_particle_source()`.
3905 """
3906 io_cfg = post_cfg.get('io', {}) or {}
3907 return bool(io_cfg.get('output_particles')) or bool(post_cfg.get('lagrangian_pipeline')) or _post_requests_statistics(post_cfg)
3908
3909
3910def _iter_post_steps(start_step: int, end_step: int, step_interval: int):
3911 """!
3912 @brief Yield configured post-processing steps inclusively.
3913 @param[in] start_step Argument passed to `_iter_post_steps()`.
3914 @param[in] end_step Argument passed to `_iter_post_steps()`.
3915 @param[in] step_interval Argument passed to `_iter_post_steps()`.
3916 """
3917 if step_interval <= 0 or end_step < start_step:
3918 return
3919 step = start_step
3920 while step <= end_step:
3921 yield step
3922 step += step_interval
3923
3924
3925def resolve_post_requested_window(post_cfg: dict, case_cfg: dict = None) -> "tuple[int, int, int]":
3926 """!
3927 @brief Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
3928 @param[in] post_cfg Argument passed to `resolve_post_requested_window()`.
3929 @param[in] case_cfg Optional case configuration for end-step expansion.
3930 @return Value returned by `resolve_post_requested_window()`.
3931 """
3932 start_step = int(get_post_run_control_value(post_cfg, 'start_step', 0) or 0)
3933 end_step = int(get_post_run_control_value(post_cfg, 'end_step', 0) or 0)
3934 step_interval = int(get_post_run_control_value(post_cfg, 'step_interval', 1) or 1)
3935 if end_step < 0 and case_cfg:
3936 case_run = case_cfg.get('run_control', {}) or {}
3937 case_start = int(case_run.get('start_step', 0) or 0)
3938 case_total = int(case_run.get('total_steps', 0) or 0)
3939 end_step = case_start + case_total
3940 return start_step, end_step, step_interval
3941
3942
3943def prepare_effective_post_config(post_cfg: dict, resolved_source_dir: str, start_step: int = None, end_step: int = None) -> dict:
3944 """!
3945 @brief Return a copy of post_cfg with resolved source dir and optional effective bounds.
3946 @param[in] post_cfg Argument passed to `prepare_effective_post_config()`.
3947 @param[in] resolved_source_dir Argument passed to `prepare_effective_post_config()`.
3948 @param[in] start_step Argument passed to `prepare_effective_post_config()`.
3949 @param[in] end_step Argument passed to `prepare_effective_post_config()`.
3950 @return Value returned by `prepare_effective_post_config()`.
3951 """
3952 effective_cfg = copy.deepcopy(post_cfg)
3953 if not isinstance(effective_cfg.get('source_data'), dict):
3954 effective_cfg['source_data'] = {}
3955 effective_cfg['source_data']['directory'] = resolved_source_dir
3956 rc = effective_cfg.setdefault('run_control', {})
3957 if start_step is not None:
3958 rc['start_step'] = int(start_step)
3959 if end_step is not None:
3960 rc['end_step'] = int(end_step)
3961 return effective_cfg
3962
3963
3964def _scan_post_vtk_steps(prefix_path: str, extension: str) -> "set[int]":
3965 """!
3966 @brief Collect step numbers from VTK files named with a prefix, step suffix, and extension.
3967 @param[in] prefix_path Output path prefix before the numeric step suffix.
3968 @param[in] extension VTK file extension to match without its leading dot.
3969 @return Set of step numbers represented by matching files in the prefix directory.
3970 """
3971 directory = os.path.dirname(prefix_path)
3972 if not os.path.isdir(directory):
3973 return set()
3974 basename = os.path.basename(prefix_path)
3975 pattern = re.compile(rf'^{re.escape(basename)}_(\d+)\.{re.escape(extension)}$')
3976 steps = set()
3977 for name in os.listdir(directory):
3978 match = pattern.match(name)
3979 if match:
3980 steps.add(int(match.group(1)))
3981 return steps
3982
3983
3984def _scan_post_statistics_csv_steps(csv_path: str) -> "set[int]":
3985 """!
3986 @brief Scan step ids from the first CSV column of a statistics artifact.
3987 @param[in] csv_path Argument passed to `_scan_post_statistics_csv_steps()`.
3988 @return Value returned by `_scan_post_statistics_csv_steps()`.
3989 """
3990 if not os.path.isfile(csv_path):
3991 return set()
3992 steps = set()
3993 with open(csv_path, 'r', encoding='utf-8', errors='replace', newline='') as f:
3994 reader = csv.reader(f)
3995 for row in reader:
3996 if not row:
3997 continue
3998 head = str(row[0]).strip().lower()
3999 if head in {'step', 'timestep', 'time_step'}:
4000 continue
4001 step_val = _parse_int_loose(row[0])
4002 if step_val is not None:
4003 steps.add(step_val)
4004 return steps
4005
4006
4007def collect_post_completion_families(run_dir: str, post_cfg: dict, monitor_cfg=None) -> "list[set[int]]":
4008 """!
4009 @brief Collect per-family completed-step sets for the current post recipe.
4010 @param[in] run_dir Argument passed to `collect_post_completion_families()`.
4011 @param[in] post_cfg Argument passed to `collect_post_completion_families()`.
4012 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
4013 @return Value returned by `collect_post_completion_families()`.
4014 """
4015 io_cfg = post_cfg.get('io', {}) or {}
4016 output_dir_abs = _post_output_directory_abs(run_dir, post_cfg)
4017 families = []
4018
4019 if _post_requests_eulerian_output(post_cfg):
4020 prefix = os.path.join(output_dir_abs, io_cfg.get('output_filename_prefix', 'Field'))
4021 families.append(_scan_post_vtk_steps(prefix, 'vts'))
4022
4023 if _post_requests_particle_output(post_cfg):
4024 prefix = os.path.join(output_dir_abs, io_cfg.get('particle_filename_prefix', 'Particle'))
4025 families.append(_scan_post_vtk_steps(prefix, 'vtp'))
4026
4027 for stats_path in get_post_statistics_output_artifacts(post_cfg, run_dir, monitor_cfg):
4028 families.append(_scan_post_statistics_csv_steps(stats_path))
4029
4030 # Each window and format is its own family, so a run that produced the Eulerian
4031 # fields but not the statistics is not mistaken for a completed step.
4032 for kind, path in get_post_field_statistics_artifacts(post_cfg, run_dir):
4033 if kind == 'vtk':
4034 families.append(_scan_post_vtk_steps(path, 'vts'))
4035 else:
4036 families.append(_scan_post_statistics_csv_steps(path))
4037
4038 return families
4039
4040
4041def detect_post_completed_frontier(run_dir: str, post_cfg: dict, monitor_cfg, start_step: int, end_step: int, step_interval: int) -> dict:
4042 """!
4043 @brief Detect the highest contiguous fully completed post step for the current recipe.
4044 @param[in] run_dir Argument passed to `detect_post_completed_frontier()`.
4045 @param[in] post_cfg Argument passed to `detect_post_completed_frontier()`.
4046 @param[in] monitor_cfg Argument passed to `detect_post_completed_frontier()`.
4047 @param[in] start_step Argument passed to `detect_post_completed_frontier()`.
4048 @param[in] end_step Argument passed to `detect_post_completed_frontier()`.
4049 @param[in] step_interval Argument passed to `detect_post_completed_frontier()`.
4050 @return Value returned by `detect_post_completed_frontier()`.
4051 """
4052 families = collect_post_completion_families(run_dir, post_cfg, monitor_cfg)
4053 frontier = None
4054 if families:
4055 for step in _iter_post_steps(start_step, end_step, step_interval):
4056 if all(step in family for family in families):
4057 frontier = step
4058 else:
4059 break
4060 return {
4061 'frontier_step': frontier,
4062 'artifact_family_count': len(families),
4063 }
4064
4065
4066def _nearest_step(steps: "set[int]", target: int):
4067 """!
4068 @brief Return the complete source step nearest to a target step.
4069 @param[in] steps Candidate step numbers.
4070 @param[in] target Target step number.
4071 @return Nearest candidate, or None when no candidates exist.
4072 """
4073 if not steps:
4074 return None
4075 return min(steps, key=lambda step: (abs(step - target), step))
4076
4077
4078def _format_optional_step(step) -> str:
4079 """!
4080 @brief Format an optional step number for user-facing diagnostics.
4081 @param[in] step Step number or None.
4082 @return Printable step text.
4083 """
4084 return 'none' if step is None else str(step)
4085
4086
4087def _scan_complete_source_steps(source_dir: str, monitor_cfg: dict, post_cfg: dict) -> "tuple[set[int], dict]":
4088 """!
4089 @brief Scan source artifacts and return steps with every file required by the recipe.
4090 @param[in] source_dir Source output root directory.
4091 @param[in] monitor_cfg Parsed monitor configuration.
4092 @param[in] post_cfg Parsed post-processing configuration.
4093 @return Tuple of complete source steps and source path metadata.
4094 """
4095 del monitor_cfg
4096 require_particles = _post_needs_particle_source(post_cfg)
4097 complete_steps = _scan_committed_checkpoint_steps(
4098 source_dir, require_particles=require_particles
4099 )
4100 return complete_steps, {
4101 'source_dir': os.path.abspath(source_dir),
4102 'require_particles': require_particles,
4103 }
4104
4105
4106def _expected_source_paths_for_step(step: int, source_scan: dict, post_cfg: dict) -> "list[str]":
4107 """!
4108 @brief Build required source file paths for a single post-processing step.
4109 @param[in] step Requested step number.
4110 @param[in] source_scan Metadata returned by `_scan_complete_source_steps()`.
4111 @param[in] post_cfg Parsed post-processing configuration.
4112 @return Required source artifact paths.
4113 """
4114 del post_cfg
4115 bundle = _checkpoint_bundle_path(source_scan['source_dir'], step)
4116 paths = [os.path.join(bundle, 'checkpoint.meta'), os.path.join(bundle, 'COMMITTED')]
4117 if source_scan.get('require_particles'):
4118 paths.append(os.path.join(bundle, 'particles', 'position.dat'))
4119 return paths
4120
4121
4122def detect_post_source_frontier(source_dir: str, monitor_cfg: dict, post_cfg: dict, start_step: int, end_step: int, step_interval: int) -> dict:
4123 """!
4124 @brief Detect the highest contiguous fully available source step for live post-processing.
4125 @param[in] source_dir Argument passed to `detect_post_source_frontier()`.
4126 @param[in] monitor_cfg Argument passed to `detect_post_source_frontier()`.
4127 @param[in] post_cfg Argument passed to `detect_post_source_frontier()`.
4128 @param[in] start_step Argument passed to `detect_post_source_frontier()`.
4129 @param[in] end_step Argument passed to `detect_post_source_frontier()`.
4130 @param[in] step_interval Argument passed to `detect_post_source_frontier()`.
4131 @return Value returned by `detect_post_source_frontier()`.
4132 """
4133 diagnostic = {
4134 'first_requested_step': start_step,
4135 'first_incomplete_step': None,
4136 'missing_files_for_first_incomplete_step': [],
4137 'closest_complete_step_to_start': None,
4138 'closest_complete_step_to_end': None,
4139 }
4140 if step_interval <= 0 or end_step < start_step or not os.path.isdir(source_dir):
4141 return {
4142 'frontier_step': None,
4143 'diagnostic': diagnostic,
4144 }
4145
4146 complete_steps, source_scan = _scan_complete_source_steps(source_dir, monitor_cfg, post_cfg)
4147 diagnostic['closest_complete_step_to_start'] = _nearest_step(complete_steps, start_step)
4148 diagnostic['closest_complete_step_to_end'] = _nearest_step(complete_steps, end_step)
4149
4150 frontier = None
4151 for step in _iter_post_steps(start_step, end_step, step_interval):
4152 try:
4154 source_dir, step,
4155 require_particles=source_scan.get('require_particles', False),
4156 )
4157 except ValueError:
4158 expected_paths = _expected_source_paths_for_step(step, source_scan, post_cfg)
4159 diagnostic['first_incomplete_step'] = step
4160 diagnostic['missing_files_for_first_incomplete_step'] = [
4161 os.path.relpath(path, source_dir) for path in expected_paths if not os.path.isfile(path)
4162 ]
4163 if not diagnostic['missing_files_for_first_incomplete_step']:
4164 diagnostic['missing_files_for_first_incomplete_step'] = [
4165 os.path.relpath(_checkpoint_bundle_path(source_dir, step), source_dir)
4166 + ' (invalid bundle)'
4167 ]
4168 break
4169 frontier = step
4170 return {
4171 'frontier_step': frontier,
4172 'diagnostic': diagnostic,
4173 }
4174
4175
4176def persist_post_resume_state(run_dir: str, plan: dict, last_successful_requested_end_step=None):
4177 """!
4178 @brief Persist post resume lineage metadata for future --continue runs.
4179 @param[in] run_dir Argument passed to `persist_post_resume_state()`.
4180 @param[in] plan Argument passed to `persist_post_resume_state()`.
4181 @param[in] last_successful_requested_end_step Argument passed to `persist_post_resume_state()`.
4182 @return Value returned by `persist_post_resume_state()`.
4183 """
4184 state_path = plan.get("resume_state_path") or get_post_resume_state_path(run_dir)
4185 payload = {
4186 'schema_version': POST_RESUME_SCHEMA_VERSION,
4187 'run_id': plan.get('run_id'),
4188 'recipe_fingerprint': plan.get('recipe_fingerprint'),
4189 'recipe_signature': plan.get('recipe_signature'),
4190 'requested_start_step': plan.get('requested_start_step'),
4191 'requested_end_step': plan.get('requested_end_step'),
4192 'step_interval': plan.get('step_interval'),
4193 'source_directory': plan.get('source_data_directory'),
4194 'resume_match_source': plan.get('resume_match_source'),
4195 'last_successful_requested_end_step': last_successful_requested_end_step,
4196 'updated_at': datetime.now().isoformat(),
4197 }
4198 write_json_file(state_path, payload)
4199 return state_path
4200
4201
4203 """!
4204 @brief Return the Python wrapper used to hold an exclusive post-stage lock.
4205 @return Value returned by `_build_post_lock_wrapper_source()`.
4206 """
4207 return """#!/usr/bin/env python3
4208import argparse
4209import fcntl
4210import json
4211import os
4212import socket
4213import subprocess
4214import sys
4215import time
4216
4217
4218def main():
4219 parser = argparse.ArgumentParser(description='PICurv post-stage lock wrapper')
4220 parser.add_argument('--lock-file', required=True)
4221 parser.add_argument('--metadata-file', required=True)
4222 parser.add_argument('--run-dir', required=True)
4223 parser.add_argument('--recipe-fingerprint', required=True)
4224 parser.add_argument('command', nargs=argparse.REMAINDER)
4225 args = parser.parse_args()
4226
4227 command = list(args.command or [])
4228 if not command or command[0] != '--':
4229 parser.error("expected '-- <command ...>' after wrapper arguments")
4230 command = command[1:]
4231
4232 os.makedirs(os.path.dirname(args.lock_file), exist_ok=True)
4233 fd = os.open(args.lock_file, os.O_RDWR | os.O_CREAT, 0o644)
4234 try:
4235 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
4236 except BlockingIOError:
4237 owner = None
4238 try:
4239 with open(args.metadata_file, 'r', encoding='utf-8') as handle:
4240 owner = json.load(handle)
4241 except Exception:
4242 owner = None
4243 if owner:
4244 print(
4245 f"[FATAL] Post stage already active for {args.run_dir} "
4246 f"(pid={owner.get('pid')}, host={owner.get('host')}, started_at={owner.get('started_at')}).",
4247 file=sys.stderr,
4248 )
4249 else:
4250 print(f"[FATAL] Post stage already active for {args.run_dir}.", file=sys.stderr)
4251 return 2
4252
4253 metadata = {
4254 'pid': os.getpid(),
4255 'host': socket.gethostname(),
4256 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
4257 'run_dir': args.run_dir,
4258 'recipe_fingerprint': args.recipe_fingerprint,
4259 'command': command,
4260 }
4261 with open(args.metadata_file, 'w', encoding='utf-8') as handle:
4262 json.dump(metadata, handle, indent=2, sort_keys=True)
4263 handle.write('\\n')
4264
4265 try:
4266 result = subprocess.run(command)
4267 return int(result.returncode)
4268 finally:
4269 try:
4270 os.remove(args.metadata_file)
4271 except FileNotFoundError:
4272 pass
4273 os.close(fd)
4274
4275
4276if __name__ == '__main__':
4277 raise SystemExit(main())
4278"""
4279
4280
4281def ensure_post_lock_wrapper(run_dir: str) -> str:
4282 """!
4283 @brief Ensure the lock wrapper exists for a run directory and return its path.
4284 @param[in] run_dir Argument passed to `ensure_post_lock_wrapper()`.
4285 @return Value returned by `ensure_post_lock_wrapper()`.
4286 """
4287 paths = get_post_lock_paths(run_dir)
4288 wrapper_path = paths['wrapper_path']
4290 existing_content = None
4291 os.makedirs(os.path.dirname(wrapper_path), exist_ok=True)
4292 if os.path.isfile(wrapper_path):
4293 with open(wrapper_path, 'r', encoding='utf-8', errors='replace') as f:
4294 existing_content = f.read()
4295 if existing_content != content:
4296 with open(wrapper_path, 'w', encoding='utf-8') as f:
4297 f.write(content)
4298 os.chmod(wrapper_path, 0o755)
4299 return wrapper_path
4300
4301
4302def build_post_locked_command(run_dir: str, recipe_fingerprint: str, wrapped_command: list, create_wrapper: bool = True) -> "tuple[list, dict]":
4303 """!
4304 @brief Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
4305 @param[in] run_dir Argument passed to `build_post_locked_command()`.
4306 @param[in] recipe_fingerprint Argument passed to `build_post_locked_command()`.
4307 @param[in] wrapped_command Argument passed to `build_post_locked_command()`.
4308 @param[in] create_wrapper Argument passed to `build_post_locked_command()`.
4309 @return Value returned by `build_post_locked_command()`.
4310 """
4311 lock_paths = get_post_lock_paths(run_dir)
4312 wrapper_path = ensure_post_lock_wrapper(run_dir) if create_wrapper else lock_paths['wrapper_path']
4313 command = [
4314 wrapper_path,
4315 '--lock-file', lock_paths['lock_file'],
4316 '--metadata-file', lock_paths['metadata_file'],
4317 '--run-dir', run_dir,
4318 '--recipe-fingerprint', recipe_fingerprint,
4319 '--',
4320 ] + list(wrapped_command)
4321 return command, lock_paths
4322
4323
4325 run_dir: str,
4326 run_id: str,
4327 case_cfg: dict,
4328 monitor_cfg: dict,
4329 post_cfg: dict,
4330 continue_requested: bool = False,
4331 allow_source_frontier_scan: bool = True,
4332) -> dict:
4333 """!
4334 @brief Resolve post resume/source-availability behavior into one execution plan.
4335 @param[in] run_dir Argument passed to `build_post_execution_plan()`.
4336 @param[in] run_id Argument passed to `build_post_execution_plan()`.
4337 @param[in] case_cfg Argument passed to `build_post_execution_plan()`.
4338 @param[in] monitor_cfg Argument passed to `build_post_execution_plan()`.
4339 @param[in] post_cfg Argument passed to `build_post_execution_plan()`.
4340 @param[in] continue_requested Argument passed to `build_post_execution_plan()`.
4341 @param[in] allow_source_frontier_scan Argument passed to `build_post_execution_plan()`.
4342 @return Value returned by `build_post_execution_plan()`.
4343 """
4344 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
4345 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
4346 requested_start_step, requested_end_step, step_interval = resolve_post_requested_window(post_cfg, case_cfg)
4347 resolved_source_dir = _resolve_post_source_directory_preview(run_dir, monitor_cfg, post_cfg)
4348 resolved_post_cfg = prepare_effective_post_config(post_cfg, resolved_source_dir)
4349 recipe_cfg = build_post_recipe_config(resolved_post_cfg, monitor_cfg)
4350 recipe_signature, recipe_fingerprint = compute_post_recipe_fingerprint(recipe_cfg)
4351
4352 state_path = get_post_resume_state_path(run_dir, resolved_post_cfg)
4353 state_payload = _read_json_if_exists(state_path)
4354 state_match = bool(isinstance(state_payload, dict) and state_payload.get('recipe_fingerprint') == recipe_fingerprint)
4355
4356 legacy_post_run_path = os.path.join(run_dir, 'config', 'post.run')
4357 legacy_recipe_cfg = parse_post_recipe_file(legacy_post_run_path)
4358 legacy_recipe_signature = normalize_post_recipe_signature(legacy_recipe_cfg or {}) if legacy_recipe_cfg else None
4359 legacy_match = bool(legacy_recipe_signature and legacy_recipe_signature == recipe_signature)
4360
4361 resume_recipe_match = False
4362 resume_match_source = None
4363 resume_bootstrapped = False
4364 if continue_requested:
4365 if state_match:
4366 resume_recipe_match = True
4367 resume_match_source = 'state'
4368 elif not state_payload and legacy_match:
4369 resume_recipe_match = True
4370 resume_match_source = 'legacy_post_run'
4371 resume_bootstrapped = True
4372
4373 completion_info = detect_post_completed_frontier(
4374 run_dir,
4375 resolved_post_cfg,
4376 monitor_cfg,
4377 requested_start_step,
4378 requested_end_step,
4379 step_interval,
4380 )
4381 completed_frontier_step = completion_info['frontier_step']
4382 if completion_info['artifact_family_count'] == 0 and state_match:
4383 completed_frontier_step = _parse_int_loose(state_payload.get('last_successful_requested_end_step'))
4384
4385 if continue_requested and resume_recipe_match and completed_frontier_step is not None:
4386 effective_start_step = completed_frontier_step + step_interval
4387 else:
4388 effective_start_step = requested_start_step
4389
4390 source_frontier_step = None
4391 source_frontier_diagnostic = None
4392 source_frontier_deferred = not allow_source_frontier_scan
4393 skip_reason = None
4394 if effective_start_step > requested_end_step:
4395 skip_reason = 'already-complete-window'
4396 effective_end_step = requested_end_step
4397 elif allow_source_frontier_scan:
4398 source_frontier_info = detect_post_source_frontier(
4399 resolved_source_dir,
4400 monitor_cfg,
4401 resolved_post_cfg,
4402 effective_start_step,
4403 requested_end_step,
4404 step_interval,
4405 )
4406 source_frontier_step = source_frontier_info['frontier_step']
4407 source_frontier_diagnostic = source_frontier_info['diagnostic']
4408 if source_frontier_step is None or source_frontier_step < effective_start_step:
4409 if continue_requested and resume_recipe_match and completed_frontier_step is not None:
4410 skip_reason = 'already-caught-up-to-current-source-frontier'
4411 else:
4412 skip_reason = 'nothing-available-yet'
4413 effective_end_step = None
4414 else:
4415 effective_end_step = min(requested_end_step, source_frontier_step)
4416 else:
4417 effective_end_step = requested_end_step
4418
4419 effective_post_cfg = None
4420 if skip_reason is None:
4421 effective_post_cfg = prepare_effective_post_config(
4422 post_cfg,
4423 resolved_source_dir,
4424 start_step=effective_start_step,
4425 end_step=effective_end_step,
4426 )
4427
4428 return {
4429 'run_id': run_id,
4430 'continue_requested': bool(continue_requested),
4431 'requested_start_step': requested_start_step,
4432 'requested_end_step': requested_end_step,
4433 'step_interval': step_interval,
4434 'source_data_directory': resolved_source_dir,
4435 'recipe_config': recipe_cfg,
4436 'recipe_signature': recipe_signature,
4437 'recipe_fingerprint': recipe_fingerprint,
4438 'resume_state_path': state_path,
4439 'resume_state_payload': state_payload,
4440 'resume_recipe_match': resume_recipe_match,
4441 'resume_match_source': resume_match_source,
4442 'resume_bootstrapped': resume_bootstrapped,
4443 'completed_frontier_step': completed_frontier_step,
4444 'source_frontier_step': source_frontier_step,
4445 'source_frontier_diagnostic': source_frontier_diagnostic,
4446 'source_frontier_deferred': source_frontier_deferred,
4447 'effective_start_step': effective_start_step,
4448 'effective_end_step': effective_end_step,
4449 'skip_reason': skip_reason,
4450 'resolved_post_cfg': resolved_post_cfg,
4451 'effective_post_cfg': effective_post_cfg,
4452 'lock_paths': get_post_lock_paths(
4453 run_dir, ((resolved_post_cfg.get("_picurv_paths") or {}).get("recipe_id"))
4454 ),
4455 }
4456
4457
4458def needs_restart_source(case_cfg: dict, solver_cfg: dict) -> bool:
4459 """!
4460 @brief Return True when the solver requires restart data from disk.
4461 @details Correctly identifies that analytical + init + start_step > 0 does NOT
4462 need a restart source (C code never reads from restart_dir in that case).
4463 @param[in] case_cfg Parsed case YAML dictionary.
4464 @param[in] solver_cfg Parsed solver YAML dictionary.
4465 @return True if a restart source (--restart-from or --continue) is required.
4466 """
4467 try:
4468 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
4469 except (TypeError, ValueError):
4470 start_step = 0
4471 eulerian_source = str(
4472 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
4473 ).strip().lower()
4474 particle_restart_mode = str(
4475 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
4476 ).strip().lower()
4477 euler_needs = (eulerian_source == "load") or (eulerian_source == "solve" and start_step > 0)
4478 particle_needs = (particle_restart_mode == "load")
4479 return euler_needs or particle_needs
4480
4481
4482def resolve_run_output_dir(run_dir: str, monitor_cfg: dict) -> str:
4483 """!
4484 @brief Resolve the output data directory within a run directory.
4485 @param[in] run_dir Path to the run directory.
4486 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4487 @return Absolute path to the output directory.
4488 """
4489 del monitor_cfg
4490 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["output"]))
4491
4492
4493def resolve_run_restart_dir(run_dir: str, monitor_cfg: dict) -> str:
4494 """!
4495 @brief Resolve the restart staging directory within a run directory.
4496 @param[in] run_dir Path to the run directory.
4497 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4498 @return Absolute path to the restart directory.
4499 """
4500 del monitor_cfg
4501 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["restart"]))
4502
4503
4504def compute_physical_case_identity(case_cfg: dict) -> str:
4505 """!
4506 @brief Compute the hidden identity used to guard in-place continuation.
4507 @details Run length/timestep controls and particle load-vs-init policy do
4508 not define the physical case. All other case.yml content does.
4509 @param[in] case_cfg Parsed case configuration.
4510 @return Lowercase SHA-256 identity of normalized physical-case content.
4511 """
4512 normalized = copy.deepcopy(case_cfg)
4513 if isinstance(normalized, dict):
4514 normalized.pop("run_control", None)
4515 particles = (((normalized.get("models") or {}).get("physics") or {}).get("particles"))
4516 if isinstance(particles, dict):
4517 particles.pop("restart_mode", None)
4518 payload = json.dumps(
4519 normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False
4520 ).encode("utf-8")
4521 return hashlib.sha256(payload).hexdigest()
4522
4523
4524def validate_continue_case_identity(run_dir: str, case_cfg: dict) -> None:
4525 """!
4526 @brief Reject in-place continuation when the physical case has changed.
4527 @param[in] run_dir Existing run directory being continued.
4528 @param[in] case_cfg Newly requested case configuration.
4529 @return None.
4530 """
4531 saved_case_path = os.path.join(run_dir, "config", "case.yml")
4532 if not os.path.isfile(saved_case_path):
4533 raise ValueError(f"--continue run is missing its saved case.yml: {saved_case_path}")
4534 saved_case = read_yaml_file(saved_case_path)
4536 raise ValueError(
4537 "--continue cannot change the physical case. Change only run_control, "
4538 "solver.yml, monitor.yml, or post.yml; use --restart-from for a new case branch."
4539 )
4540
4541
4542def populate_restart_directory(source_output: str, target_restart: str, start_step: int,
4543 monitor_cfg: dict, end_step: "int | None" = None,
4544 materialize: bool = True):
4545 """!
4546 @brief Atomically materialize an immutable checkpoint interval into a run.
4547 @param[in] source_output Path to the source output directory containing checkpoint data.
4548 @param[in] target_restart Path to the target restart directory to populate.
4549 @param[in] start_step First checkpoint step to materialize.
4550 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
4551 @param[in] end_step Optional inclusive last checkpoint step.
4552 @param[in] materialize Whether to copy the bundles, or only validate and resolve
4553 the path a real run would use. The dry-run planner needs the second:
4554 it reports what a run would refuse, and promises to write nothing.
4555 @return Canonical restart root containing committed checkpoint bundles.
4556 """
4557 del monitor_cfg
4558 checkpoints_root = os.path.join(os.path.abspath(target_restart), "checkpoints")
4559 if materialize:
4560 os.makedirs(checkpoints_root, exist_ok=True)
4561 final_step = start_step if end_step is None else end_step
4562 if final_step < start_step:
4563 raise ValueError("Restart checkpoint interval end must not precede its start.")
4564 for step in range(start_step, final_step + 1):
4565 source = validate_committed_checkpoint(source_output, step)["bundle"]
4566 if not materialize:
4567 continue
4568 destination = os.path.join(
4569 checkpoints_root, f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}"
4570 )
4571 if os.path.isdir(destination):
4572 validate_committed_checkpoint(destination, step)
4573 print(f"[INFO] Reusing committed restart bundle: {destination}")
4574 continue
4575 temporary = os.path.join(
4576 checkpoints_root,
4577 f".step_{step:0{CHECKPOINT_STEP_WIDTH}d}.copying.{os.getpid()}",
4578 )
4579 if os.path.exists(temporary):
4580 raise ValueError(f"Restart staging path already exists: {temporary}")
4581 try:
4582 os.makedirs(temporary)
4583 for current, dirnames, filenames in os.walk(source):
4584 relative = os.path.relpath(current, source)
4585 target_dir = temporary if relative == "." else os.path.join(temporary, relative)
4586 os.makedirs(target_dir, exist_ok=True)
4587 for dirname in dirnames:
4588 os.makedirs(os.path.join(target_dir, dirname), exist_ok=True)
4589 for filename in filenames:
4591 os.path.join(current, filename), os.path.join(target_dir, filename)
4592 )
4593 validate_committed_checkpoint(temporary, step)
4594 os.replace(temporary, destination)
4595 except Exception:
4596 if os.path.isdir(temporary):
4597 shutil.rmtree(temporary)
4598 raise
4599 print(f"[INFO] Materialized committed restart bundle for step {step}: {destination}")
4600 return os.path.abspath(target_restart)
4601
4602
4603def validate_eulerian_checkpoint(source_dir: str, step: int, monitor_cfg: dict):
4604 """!
4605 @brief Validate the mandatory Eulerian field set required by `ReadSimulationFields()`.
4606 @param[in] source_dir Root directory containing the Eulerian subdirectory.
4607 @param[in] step Checkpoint step to validate.
4608 @param[in] monitor_cfg Monitor configuration defining the Eulerian subdirectory.
4609 @return Validated checkpoint description.
4610 @throws ValueError if any mandatory Eulerian field is absent.
4611 """
4612 del monitor_cfg
4613 return validate_committed_checkpoint(source_dir, step)
4614
4615
4616def detect_last_checkpoint_step(output_dir: str):
4617 """!
4618 @brief Scan output directory for the highest step number available.
4619 @details Ignores incomplete temporary directories and invalid bundles.
4620 @param[in] output_dir Path to the output directory.
4621 @return The highest step number found, or None if no checkpoints exist.
4622 """
4623 steps = _scan_committed_checkpoint_steps(output_dir)
4624 return max(steps) if steps else None
4625
4626
4627def detect_case_completion_status(run_dir: str, monitor_cfg: dict, target_final_step: int) -> dict:
4628 """!
4629 @brief Determine whether a study case is complete, partially complete, or empty.
4630 @param[in] run_dir Path to the case run directory.
4631 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4632 @param[in] target_final_step The step number the case should reach for completion.
4633 @return Dictionary with keys 'last_step' (int or None), 'target_step' (int),
4634 and 'status' ('complete', 'partial', or 'empty').
4635 """
4636 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
4637 last_step = detect_last_checkpoint_step(output_dir)
4638 if last_step is not None and last_step >= target_final_step:
4639 status = "complete"
4640 elif last_step is not None:
4641 status = "partial"
4642 else:
4643 status = "empty"
4644 return {"last_step": last_step, "target_step": target_final_step, "status": status}
4645
4646
4647def validate_load_mode_step_range(source_output: str, start_step: int, total_steps: int, monitor_cfg: dict):
4648 """!
4649 @brief Validate that all required eulerian step files exist for "load" mode.
4650 @details Checks that ufield files exist for every step from start_step through
4651 start_step + total_steps (inclusive). Reports missing steps clearly.
4652 @param[in] source_output Path to the output directory containing eulerian data.
4653 @param[in] start_step First step that will be loaded.
4654 @param[in] total_steps Number of steps to run.
4655 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
4656 """
4657 missing = []
4658 for step in range(start_step, start_step + total_steps + 1):
4659 try:
4660 validate_eulerian_checkpoint(source_output, step, monitor_cfg)
4661 except ValueError:
4662 missing.append(step)
4663
4664 if missing:
4665 sample = missing[:3] + (["..."] if len(missing) > 6 else []) + missing[-3:]
4666 raise ValueError(
4667 f"Eulerian 'load' mode: {len(missing)} committed checkpoint(s) missing in {source_output}. "
4668 f"Missing steps include: {sample}"
4669 )
4670
4671
4672def validate_particle_checkpoint(source_dir: str, start_step: int, monitor_cfg: dict):
4673 """!
4674 @brief Validate that particle checkpoint files exist for the given step.
4675 @details Checks that at least a position file exists at the expected step in
4676 the particle subdirectory.
4677 @param[in] source_dir Path to the directory containing the particle subdirectory.
4678 @param[in] start_step The step number whose particle checkpoint is expected.
4679 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
4680 @return Validated checkpoint description.
4681 """
4682 del monitor_cfg
4683 return validate_committed_checkpoint(source_dir, start_step, require_particles=True)
4684
4685
4686def read_monitor_from_run(run_dir: str) -> dict:
4687 """!
4688 @brief Read the monitor.yml from a run directory's config/ subdirectory.
4689 @param[in] run_dir Path to the run directory.
4690 @return Parsed monitor YAML dictionary.
4691 """
4692 monitor_path = os.path.join(run_dir, "config", "monitor.yml")
4693 if not os.path.isfile(monitor_path):
4694 raise ValueError(f"Run directory is missing config/monitor.yml: {monitor_path}")
4695 return read_yaml_file(monitor_path)
4696
4697
4698def resolve_latest_restart_run(case_cfg: dict, case_path: str, start_step: int) -> str:
4699 """!
4700 @brief Select the newest local workspace run compatible with a requested restart.
4701 @param[in] case_cfg Current case configuration.
4702 @param[in] case_path Current case path.
4703 @param[in] start_step Required committed checkpoint.
4704 @return Absolute source run directory.
4705 """
4706 workspace_root = find_workspace_root(case_path, os.getcwd())
4707 if not workspace_root:
4708 raise ValueError("--from latest requires an initialized workspace.")
4709 current_graph = build_case_asset_graph(case_cfg, case_path)
4710 current_grid = next(
4711 (item for item in current_graph.get("providers", []) if item.get("kind") == "grid"),
4712 None,
4713 )
4714 candidates = []
4715 for candidate in Path(workspace_root, "runs").iterdir():
4716 if not candidate.is_dir():
4717 continue
4718 manifest = _read_json_if_exists(str(candidate / "manifest.json")) or {}
4719 lock = read_yaml_file(str(candidate / "inputs" / "assets.lock.yml")) \
4720 if (candidate / "inputs" / "assets.lock.yml").is_file() else {}
4721 locked_grid = (lock.get("assets") or {}).get("grid")
4722 runtime_grid = (lock.get("runtime_providers") or {}).get("grid")
4723 if current_grid:
4724 recorded_hash = None
4725 if isinstance(locked_grid, dict):
4726 recorded_hash = locked_grid.get("provider_spec_sha256")
4727 elif isinstance(runtime_grid, dict):
4728 recorded_hash = runtime_grid.get("provider_spec_sha256")
4729 if recorded_hash and recorded_hash != current_grid.get("spec_sha256"):
4730 continue
4731 output_root = candidate / CANONICAL_RUN_PATHS["output"]
4732 try:
4733 validate_committed_checkpoint(str(output_root), start_step)
4734 except (OSError, ValueError):
4735 continue
4736 ordering = manifest.get("updated_at") or manifest.get("created_at") or ""
4737 candidates.append((ordering, candidate.stat().st_mtime_ns, str(candidate)))
4738 if not candidates:
4739 raise ValueError(
4740 f"No local workspace run has a compatible committed checkpoint at step {start_step}. "
4741 "Restore a run from storage first, or name one explicitly with --from <run-dir>."
4742 )
4743 candidates.sort(reverse=True)
4744 selected = candidates[0][2]
4745 print(f"[INFO] Selected latest compatible restart run: {os.path.relpath(selected, workspace_root)}")
4746 return selected
4747
4748
4749def resolve_restart_source(args, case_cfg: dict, solver_cfg: dict, monitor_cfg: dict,
4750 run_dir: str, materialize: bool = True):
4751 """!
4752 @brief Resolve the restart source directory based on --restart-from or --continue CLI flags.
4753 @details Implements the full restart resolution logic including smart resolution for
4754 --continue (checks restart/ first for user-curated data, falls back to output/)
4755 and direct reference for eulerian "load" mode.
4756 @param[in] args Parsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
4757 @param[in] case_cfg Parsed case YAML dictionary.
4758 @param[in] solver_cfg Parsed solver YAML dictionary.
4759 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4760 @param[in] run_dir Path to the current run directory.
4761 @param[in] materialize Whether to copy restart bundles into the run. The dry-run
4762 planner passes False: it still validates and resolves, but writes nothing.
4763 @return Tuple of (restart_source_dir, continue_mode, lineage) where restart_source_dir
4764 is the resolved path (or None), continue_mode is a boolean, and lineage is the
4765 branch provenance record from `build_run_lineage()`, or None when this run did
4766 not branch from another.
4767 """
4768 try:
4769 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
4770 except (TypeError, ValueError):
4771 start_step = 0
4772 try:
4773 total_steps = int(case_cfg.get("run_control", {}).get("total_steps", 0) or 0)
4774 except (TypeError, ValueError):
4775 total_steps = 0
4776
4777 eulerian_source = str(
4778 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
4779 ).strip().lower()
4780
4781 particle_restart_mode = str(
4782 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
4783 ).strip().lower()
4784 particle_needs = (particle_restart_mode == "load")
4785
4786 requires_source = needs_restart_source(case_cfg, solver_cfg)
4787 restart_from = getattr(args, 'restart_from', None)
4788 continue_run = getattr(args, 'continue_run', False)
4789
4790 if restart_from and continue_run:
4791 raise ValueError("--restart-from and --continue are mutually exclusive.")
4792
4793 if continue_run and start_step <= 0:
4794 raise ValueError(
4795 "--continue with --solve requires run_control.start_step > 0; "
4796 "start_step=0 is a fresh start. Omit --continue to start a fresh run."
4797 )
4798
4799 if restart_from:
4800 requested_restart_source = str(restart_from)
4801 if str(restart_from).strip().lower() == "latest":
4802 restart_from = resolve_latest_restart_run(
4803 case_cfg, getattr(args, "case", "case.yml"), start_step
4804 )
4805 # A branch may follow a different physical trajectory than the samples already
4806 # collected, so the retention of the parent's windows is the user's decision and
4807 # not a default's. Silently resetting reports a sample count that describes a
4808 # shorter average than the user expects; silently carrying averages two
4809 # different flows together. Neither is safe to guess, so the flag is required
4810 # whenever there are windows to decide about.
4811 requested_statistics_state = getattr(args, "statistics_state", None)
4812 statistics_enabled = normalize_field_statistics_config(monitor_cfg or {})["enabled"]
4813 if requested_statistics_state is None:
4814 if statistics_enabled:
4815 raise ValueError(
4816 "A branched restart of a run with field_statistics.enabled: true must "
4817 "state what happens to the parent's accumulated windows. Pass "
4818 "--statistics-state reset to discard them and start the averages over, "
4819 "or --statistics-state carry to resume compatible saved window state."
4820 )
4821 requested_statistics_state = "reset"
4822 statistics_state = str(requested_statistics_state).lower()
4823 if statistics_state == "carry" and not statistics_enabled:
4824 raise ValueError("--statistics-state carry requires field_statistics.enabled: true.")
4825
4826 # === MODE 1: New run, restart from another run ===
4827 source_run = os.path.abspath(restart_from)
4828 if not os.path.isdir(source_run):
4829 raise ValueError(f"--restart-from run directory does not exist: {source_run}")
4830 try:
4831 require_storage_payload_local(source_run, "--restart-from", checkpoint=start_step)
4832 except StorageError as exc:
4833 raise ValueError(str(exc)) from exc
4834 source_monitor = read_monitor_from_run(source_run)
4835 source_output = resolve_run_output_dir(source_run, source_monitor)
4836 if not os.path.isdir(source_output):
4837 raise ValueError(f"Source output directory does not exist: {source_output}")
4838 lineage = build_run_lineage(
4839 source_run, start_step,
4840 workspace_root=find_workspace_root(run_dir),
4841 statistics_state=statistics_state,
4842 requested_source=requested_restart_source,
4843 )
4844
4845 if not requires_source:
4846 # R7: analytical + init — warn that --restart-from is unused
4847 print(
4848 "[WARN] --restart-from specified but no data will be read "
4849 "(analytical + init does not need restart data).",
4850 file=sys.stderr,
4851 )
4852 return None, False, None
4853
4854 if eulerian_source == "load":
4855 # A load-mode run may consume a complete saved sequence. Materialize it
4856 # into this run so storage never depends on another run remaining local.
4857 validate_load_mode_step_range(source_output, start_step, total_steps, source_monitor)
4858 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
4859 restart_root = populate_restart_directory(
4860 source_output, target_restart, start_step, monitor_cfg,
4861 end_step=start_step + total_steps, materialize=materialize,
4862 )
4863 if particle_needs:
4865 restart_root if materialize else source_output, start_step, monitor_cfg
4866 )
4867 return restart_root, False, lineage
4868 else:
4869 # Materialize one immutable bundle into the new run's restart input.
4870 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
4871 restart_root = populate_restart_directory(
4872 source_output, target_restart, start_step, monitor_cfg,
4873 materialize=materialize,
4874 )
4875 if particle_needs:
4877 restart_root if materialize else source_output, start_step, monitor_cfg
4878 )
4879 return restart_root, False, lineage
4880
4881 elif continue_run:
4882 # === MODE 2: Continue in-place ===
4883 continue_run_dir = getattr(args, 'run_dir', None)
4884 if not continue_run_dir:
4885 raise ValueError(RESTART_RUN_DIR_REQUIRED_MESSAGE)
4886 continue_run_dir = os.path.abspath(continue_run_dir)
4887 if not os.path.isdir(continue_run_dir):
4888 raise ValueError(f"--run-dir does not exist: {continue_run_dir}")
4889 try:
4890 require_storage_payload_local(continue_run_dir, "--continue", checkpoint=start_step)
4891 except StorageError as exc:
4892 raise ValueError(str(exc)) from exc
4893 validate_continue_case_identity(continue_run_dir, case_cfg)
4894
4895 source_output = resolve_run_output_dir(continue_run_dir, monitor_cfg)
4896 # Warn if start_step != last checkpoint
4897 last_step = detect_last_checkpoint_step(source_output)
4898 if last_step is not None and last_step != start_step:
4899 print(
4900 f"[WARN] start_step={start_step} but last checkpoint in output is step {last_step}.",
4901 file=sys.stderr,
4902 )
4903
4904 if eulerian_source == "load":
4905 # Preserve the saved sequence under the run's immutable restart input.
4906 validate_load_mode_step_range(source_output, start_step, total_steps, monitor_cfg)
4907 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
4908 restart_root = populate_restart_directory(
4909 source_output, target_restart, start_step, monitor_cfg,
4910 end_step=start_step + total_steps, materialize=materialize,
4911 )
4912 if particle_needs:
4914 restart_root if materialize else source_output, start_step, monitor_cfg
4915 )
4916 return restart_root, True, None
4917 elif not requires_source:
4918 # C6: analytical + init — only log-append behavior, no data needed
4919 return None, True, None
4920 else:
4921 # In-place continuation also uses the fixed restart-input home. Reflink or
4922 # hardlink materialization normally makes this metadata-cheap.
4923 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
4924 restart_root = populate_restart_directory(
4925 source_output, target_restart, start_step, monitor_cfg,
4926 materialize=materialize,
4927 )
4929 restart_root if materialize else source_output, start_step,
4930 require_particles=particle_needs,
4931 )
4932 return restart_root, True, None
4933
4934 elif requires_source:
4935 raise ValueError(
4936 "Restart data required but no source specified. Use:\n"
4937 " --restart-from <run_dir> (new run from another run's data)\n"
4938 " --continue --run-dir <run_dir> (resume in same directory)"
4939 )
4940
4941 return None, False, None
4942
4943def absolutize_case_external_paths(case_cfg: dict, case_anchor_path: str):
4944 """!
4945 @brief Convert external grid/generator paths in case config to absolute paths.
4946 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
4947 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
4948 """
4949 # Initialized workspaces deliberately keep portable, root-relative paths in
4950 # every materialized study member. The run/study directory remains below
4951 # the workspace, so normal resolution can still find the owning manifest.
4952 if find_workspace_root(case_anchor_path):
4953 return
4954 grid_cfg = case_cfg.get("grid", {})
4955 if not isinstance(grid_cfg, dict):
4956 return
4957 mode = grid_cfg.get("mode")
4958 if mode == "file":
4959 source_file = grid_cfg.get("source_file")
4960 if isinstance(source_file, str):
4961 grid_cfg["source_file"] = resolve_path(case_anchor_path, source_file)
4962 elif mode == "grid_gen":
4963 gen = grid_cfg.get("generator", {})
4964 if isinstance(gen, dict):
4965 for key in ("script", "config_file"):
4966 val = gen.get(key)
4967 if isinstance(val, str):
4968 gen[key] = resolve_path(case_anchor_path, val)
4969 ic = (case_cfg.get("properties", {}) or {}).get("initial_conditions", {})
4970 if isinstance(ic, dict):
4971 if str(ic.get("mode", "")).strip().lower() == "file":
4972 source_file = ic.get("source_file")
4973 if isinstance(source_file, str):
4974 ic["source_file"] = resolve_path(case_anchor_path, source_file)
4975 elif str(ic.get("generator", "")).strip().lower() == "ic_gen":
4976 params = ic.get("params", {})
4977 if isinstance(params, dict):
4978 for key in ("script", "config_file"):
4979 value = params.get(key)
4980 if isinstance(value, str):
4981 params[key] = resolve_path(case_anchor_path, value)
4982 boundary_conditions = case_cfg.get("boundary_conditions", [])
4983 blocks = boundary_conditions if boundary_conditions and isinstance(boundary_conditions[0], list) else [boundary_conditions]
4984 for block in blocks:
4985 if not isinstance(block, list):
4986 continue
4987 for bc in block:
4988 if not isinstance(bc, dict) or str(bc.get("handler", "")).strip().lower() != "prescribed_flow":
4989 continue
4990 source = ((bc.get("params") or {}).get("source") or {})
4991 if not isinstance(source, dict):
4992 continue
4993 source_type = str(source.get("type", "")).strip().lower()
4994 if source_type == "file":
4995 keys = ("path",)
4996 elif source_type == "generated":
4997 keys = ("script",)
4998 elif source_type == "field_slice":
4999 keys = ("script", "field_file", "grid_file", "source_case")
5000 else:
5001 keys = ()
5002 for key in keys:
5003 value = source.get(key)
5004 if isinstance(value, str):
5005 source[key] = resolve_path(case_anchor_path, value)
5006
5007
5008def prepare_case_for_continuation(run_dir: str, case_id: str, last_step: int,
5009 target_final_step: int, cluster_cfg: dict):
5010 """!
5011 @brief Set up a partially-completed study case for continuation in-place.
5012 @details Updates the case config with new start_step/total_steps, sets particle
5013 restart_mode to 'load' if checkpoint exists, populates the restart
5014 directory, and regenerates the solver control file with continue_mode.
5015 Delegates all restart resolution to resolve_restart_source().
5016 @param[in] run_dir Path to the case run directory.
5017 @param[in] case_id The case identifier (e.g. 'case_0002').
5018 @param[in] last_step The last checkpoint step found in the output directory.
5019 @param[in] target_final_step The step number the case should reach for completion.
5020 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
5021 @return The absolute path to the regenerated control file.
5022 """
5023 config_dir = os.path.join(run_dir, "config")
5024 case_cfg = read_yaml_file(os.path.join(config_dir, "case.yml"))
5025 solver_cfg = read_yaml_file(os.path.join(config_dir, "solver.yml"))
5026 monitor_cfg = read_yaml_file(os.path.join(config_dir, "monitor.yml"))
5027
5028 remaining = target_final_step - last_step
5029 case_cfg["run_control"]["start_step"] = last_step
5030 case_cfg["run_control"]["total_steps"] = remaining
5031 print(f"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
5032
5033 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
5034 particles_cfg = (case_cfg.get("models", {}).get("physics", {}) or {}).get("particles")
5035 checkpoint_has_particles = False
5036 try:
5037 checkpoint_has_particles = validate_committed_checkpoint(
5038 output_dir, last_step, require_particles=True
5039 )["has_particles"]
5040 except ValueError:
5041 checkpoint_has_particles = False
5042 if particles_cfg is not None and checkpoint_has_particles:
5043 current_mode = str(particles_cfg.get("restart_mode", "init")).strip().lower()
5044 if current_mode != "load":
5045 particles_cfg["restart_mode"] = "load"
5046 print(f"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
5047
5048 write_yaml_file(os.path.join(config_dir, "case.yml"), case_cfg)
5049
5050 mock_args = argparse.Namespace(restart_from=None, continue_run=True, run_dir=run_dir)
5051 restart_source_dir, continue_mode, _lineage = resolve_restart_source(
5052 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
5053 )
5054
5055 source_files = {
5056 'Case': os.path.join(config_dir, "case.yml"),
5057 'Solver': os.path.join(config_dir, "solver.yml"),
5058 'Monitor': os.path.join(config_dir, "monitor.yml"),
5059 }
5060 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
5061 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
5062 configs = {
5063 "case": case_cfg, "case_path": source_files['Case'],
5064 "solver": solver_cfg, "solver_path": source_files['Solver'],
5065 "monitor": monitor_cfg, "monitor_path": source_files['Monitor'],
5066 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
5067 }
5068 control_file = generate_solver_control_file(
5069 run_dir, case_id, configs, cluster_tasks, monitor_files,
5070 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
5071 )
5072 print(f"[SUCCESS] {case_id}: regenerated control file for continuation")
5073 return control_file
5074
5075
5076def is_valid_email(email: str) -> bool:
5077 """!
5078 @brief Lightweight email validation for scheduler notifications.
5079 @param[in] email Argument passed to `is_valid_email()`.
5080 @return Value returned by `is_valid_email()`.
5081 """
5082 if not isinstance(email, str):
5083 return False
5084 pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
5085 return re.match(pattern, email.strip()) is not None
5086
5087def normalize_statistics_task(task_name: str) -> str:
5088 """!
5089 @brief Normalizes user-facing statistics task names to C pipeline keywords.
5090 @param[in] task_name Task name from YAML.
5091 @return Canonical keyword accepted by C statistics pipeline.
5092 @throws ValueError if task is unsupported.
5093 """
5094 # Only implemented statistics kernels belong here.
5095 if task_name is None:
5096 raise ValueError("statistics task cannot be None")
5097 normalized = str(task_name).strip().lower().replace("-", "_").replace(" ", "_")
5098 if normalized != "msd":
5099 raise ValueError(f"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
5100 return "ComputeMSD"
5101
5103 """!
5104 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
5105 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
5106 """
5107 for lineno, raw in enumerate(file_obj, start=1):
5108 line = raw.strip()
5109 if not line or line.startswith("#"):
5110 continue
5111 yield lineno, line
5112
5113PICGRID_FLOAT_FORMAT = ".17e"
5114
5115
5116def format_picgrid_coordinate(value: float) -> str:
5117 """!
5118 @brief Format a coordinate with round-trip-safe binary64 precision.
5119 @param[in] value Coordinate value.
5120 @return Formatted coordinate.
5121 """
5122 return format(value, PICGRID_FLOAT_FORMAT)
5123
5124
5125def validate_and_nondimensionalize_picgrid(source_grid: str, dest_grid: str, L_ref: float, expected_nblk: int = None) -> dict:
5126 """!
5127 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
5128 @details Requires canonical PICGRID input with leading "PICGRID" token.
5129 Output is always written in canonical PICGRID format with header and per-block dims.
5130 @param[in] source_grid Input grid file path.
5131 @param[in] dest_grid Output grid file path.
5132 @param[in] L_ref Reference length for non-dimensionalization.
5133 @param[in] expected_nblk Optional expected block count.
5134 @return Summary dictionary with nblk, dims, and total_nodes.
5135 @throws ValueError on malformed grid.
5136 """
5137 if L_ref == 0.0:
5138 raise ValueError("length_ref must be non-zero when processing grid coordinates.")
5139 if not os.path.isfile(source_grid):
5140 raise ValueError(f"Grid file not found: {source_grid}")
5141
5142 with open(source_grid, "r") as fin:
5143 line_iter = _iter_nonempty_noncomment_lines(fin)
5144 try:
5145 _, first_token = next(line_iter)
5146 except StopIteration:
5147 raise ValueError(f"Grid file '{source_grid}' is empty.")
5148
5149 if first_token != "PICGRID":
5150 raise ValueError(
5151 f"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
5152 )
5153 try:
5154 _, nblk_line = next(line_iter)
5155 except StopIteration:
5156 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
5157
5158 try:
5159 nblk = int(nblk_line)
5160 except ValueError:
5161 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
5162 if nblk <= 0:
5163 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
5164 if expected_nblk is not None and nblk != expected_nblk:
5165 raise ValueError(
5166 f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
5167 )
5168
5169 dims = []
5170 for bi in range(nblk):
5171 try:
5172 lineno, dim_line = next(line_iter)
5173 except StopIteration:
5174 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
5175 parts = dim_line.split()
5176 if len(parts) != 3:
5177 raise ValueError(
5178 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
5179 )
5180 try:
5181 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
5182 except ValueError:
5183 raise ValueError(
5184 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
5185 )
5186 if im <= 0 or jm <= 0 or km <= 0:
5187 raise ValueError(
5188 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
5189 )
5190 dims.append((im, jm, km))
5191
5192 total_nodes_expected = sum(im * jm * km for (im, jm, km) in dims)
5193 os.makedirs(os.path.dirname(dest_grid), exist_ok=True)
5194 with open(dest_grid, "w") as fout:
5195 fout.write("PICGRID\n")
5196 fout.write(f"{nblk}\n")
5197 for (im, jm, km) in dims:
5198 fout.write(f"{im} {jm} {km}\n")
5199
5200 total_nodes_seen = 0
5201 for lineno, coord_line in line_iter:
5202 parts = coord_line.split()
5203 if len(parts) != 3:
5204 raise ValueError(
5205 f"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
5206 )
5207 try:
5208 x = float(parts[0]) / L_ref
5209 y = float(parts[1]) / L_ref
5210 z = float(parts[2]) / L_ref
5211 except ValueError:
5212 raise ValueError(
5213 f"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
5214 )
5215 total_nodes_seen += 1
5216 if total_nodes_seen > total_nodes_expected:
5217 raise ValueError(
5218 f"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
5219 )
5220 fout.write(
5221 f"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
5222 f"{format_picgrid_coordinate(z)}\n"
5223 )
5224
5225 if total_nodes_seen != total_nodes_expected:
5226 raise ValueError(
5227 f"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
5228 )
5229
5230 return {"nblk": nblk, "dims": dims, "total_nodes": total_nodes_expected}
5231
5232def read_picgrid_header_dimensions(source_grid: str, expected_nblk: int = None) -> list:
5233 """!
5234 @brief Read only the canonical PICGRID header dimensions.
5235 @param[in] source_grid Input grid file path.
5236 @param[in] expected_nblk Optional expected block count.
5237 @return List of (IM, JM, KM) node-count tuples.
5238 @throws ValueError on malformed header.
5239 """
5240 if not os.path.isfile(source_grid):
5241 raise ValueError(f"Grid file not found: {source_grid}")
5242
5243 with open(source_grid, "r") as fin:
5244 line_iter = _iter_nonempty_noncomment_lines(fin)
5245 try:
5246 _, first_token = next(line_iter)
5247 except StopIteration:
5248 raise ValueError(f"Grid file '{source_grid}' is empty.")
5249 if first_token != "PICGRID":
5250 raise ValueError(f"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
5251
5252 try:
5253 _, nblk_line = next(line_iter)
5254 nblk = int(nblk_line)
5255 except StopIteration:
5256 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
5257 except ValueError:
5258 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
5259 if nblk <= 0:
5260 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
5261 if expected_nblk is not None and nblk != expected_nblk:
5262 raise ValueError(f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
5263
5264 dims = []
5265 for bi in range(nblk):
5266 try:
5267 lineno, dim_line = next(line_iter)
5268 except StopIteration:
5269 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
5270 parts = dim_line.split()
5271 if len(parts) != 3:
5272 raise ValueError(
5273 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
5274 )
5275 try:
5276 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
5277 except ValueError:
5278 raise ValueError(
5279 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
5280 )
5281 if im <= 0 or jm <= 0 or km <= 0:
5282 raise ValueError(
5283 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
5284 )
5285 dims.append((im, jm, km))
5286
5287 return dims
5288
5289def validate_and_nondimensionalize_picslice(source_slice: str, dest_slice: str, U_ref: float,
5290 expected_dims: tuple = None) -> dict:
5291 """!
5292 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
5293 @param[in] source_slice Input PICSLICE path.
5294 @param[in] dest_slice Output staged PICSLICE path.
5295 @param[in] U_ref Reference velocity for non-dimensionalization.
5296 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
5297 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
5298 @throws ValueError on malformed slice.
5299 """
5300 if U_ref == 0.0:
5301 raise ValueError("velocity_ref must be non-zero when processing PICSLICE speeds.")
5302 if not os.path.isfile(source_slice):
5303 raise ValueError(f"PICSLICE file not found: {source_slice}")
5304
5305 with open(source_slice, "r") as fin:
5306 line_iter = _iter_nonempty_noncomment_lines(fin)
5307 try:
5308 _, first_token = next(line_iter)
5309 except StopIteration:
5310 raise ValueError(f"PICSLICE file '{source_slice}' is empty.")
5311 if first_token != "PICSLICE":
5312 raise ValueError(f"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
5313
5314 try:
5315 _, frame_line = next(line_iter)
5316 frame_count = int(frame_line)
5317 except StopIteration:
5318 raise ValueError(f"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
5319 except ValueError:
5320 raise ValueError(f"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
5321 if frame_count != 1:
5322 raise ValueError(
5323 f"PICSLICE file '{source_slice}' has frame count {frame_count}; Phase 1 supports exactly 1."
5324 )
5325
5326 try:
5327 lineno, dim_line = next(line_iter)
5328 except StopIteration:
5329 raise ValueError(f"PICSLICE file '{source_slice}' missing slice dimensions.")
5330 parts = dim_line.split()
5331 if len(parts) != 2:
5332 raise ValueError(
5333 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
5334 )
5335 try:
5336 n1, n2 = (int(parts[0]), int(parts[1]))
5337 except ValueError:
5338 raise ValueError(
5339 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
5340 )
5341 if n1 <= 0 or n2 <= 0:
5342 raise ValueError(f"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
5343 if expected_dims is not None and (n1, n2) != tuple(expected_dims):
5344 raise ValueError(
5345 f"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
5346 )
5347
5348 values = []
5349 for lineno, value_line in line_iter:
5350 parts = value_line.split()
5351 if len(parts) != 1:
5352 raise ValueError(
5353 f"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
5354 )
5355 try:
5356 value = float(parts[0])
5357 except ValueError:
5358 raise ValueError(f"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
5359 if not math.isfinite(value):
5360 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be finite.")
5361 if value < 0.0:
5362 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
5363 values.append(value)
5364
5365 expected_count = n1 * n2
5366 if len(values) != expected_count:
5367 raise ValueError(
5368 f"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
5369 )
5370
5371 os.makedirs(os.path.dirname(dest_slice), exist_ok=True)
5372 with open(dest_slice, "w") as fout:
5373 fout.write("PICSLICE\n")
5374 fout.write("1\n")
5375 fout.write(f"{n1} {n2}\n")
5376 for value in values:
5377 fout.write(f"{value / U_ref:.8e}\n")
5378
5379 return {
5380 "frame_count": frame_count,
5381 "dims": (n1, n2),
5382 "value_count": len(values),
5383 "min_speed": min(values) if values else 0.0,
5384 "max_speed": max(values) if values else 0.0,
5385 }
5386
5387def _face_artifact_token(face: str) -> str:
5388 """!
5389 @brief Convert a BC face token into a filesystem-friendly artifact token.
5390 @param[in] face Canonical face token such as -Zeta.
5391 @return Filesystem-friendly face token.
5392 """
5393 return face.replace("+", "pos").replace("-", "neg")
5394
5395def _resolve_run_artifact_path(run_dir: str, configured_path: str, default_path: str,
5396 default_to_config_dir: bool = False) -> str:
5397 """!
5398 @brief Resolve a run artifact path with run-dir-relative defaults.
5399 @param[in] run_dir Run/precompute directory root.
5400 @param[in] configured_path Optional user-provided artifact path.
5401 @param[in] default_path Default path relative to run_dir.
5402 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
5403 @return Absolute artifact path.
5404 """
5405 path = configured_path if configured_path else default_path
5406 if not isinstance(path, str) or not path.strip():
5407 raise ValueError("generated profile output_file must be a non-empty path when provided.")
5408 path = path.strip()
5409 if os.path.isabs(path):
5410 return os.path.abspath(path)
5411 if default_to_config_dir and os.path.dirname(path) == "":
5412 path = os.path.join("config", path)
5413 return os.path.abspath(os.path.join(run_dir, path))
5414
5415def _resolve_generator_script(configured_script: str, case_path: str, default_name: str) -> str:
5416 """!
5417 @brief Resolve an optional generator script override or repository default.
5418 @param[in] configured_script Optional absolute or case-relative script path.
5419 @param[in] case_path Current case.yml path used to anchor relative overrides.
5420 @param[in] default_name Repository generator filename under GENERATORS_PATH.
5421 @return Absolute generator script path.
5422 """
5423 if configured_script is None:
5424 return os.path.join(GENERATORS_PATH, default_name)
5425 if not isinstance(configured_script, str) or not configured_script.strip():
5426 raise ValueError(f"Generator script override for {default_name} must be a non-empty path.")
5427 script = configured_script.strip()
5428 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
5429 return _resolve_case_relative_path(script, case_dir)
5430
5431def _normalize_square_duct_poiseuille_params(params, field_name: str) -> dict:
5432 """!
5433 @brief Validate square-duct Poiseuille generator parameters.
5434 @param[in] params Generator params mapping.
5435 @param[in] field_name Human-readable YAML field name for diagnostics.
5436 @return Normalized params.
5437 """
5438 if params is None:
5439 params = {}
5440 if not isinstance(params, dict):
5441 raise ValueError(f"{field_name}.params must be a mapping when provided.")
5442 unknown = sorted(set(params.keys()) - {"bulk_velocity", "n_terms"})
5443 if unknown:
5444 raise ValueError(f"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
5445 bulk_velocity = _to_float(params.get("bulk_velocity", 1.0), f"{field_name}.params.bulk_velocity")
5446 if bulk_velocity <= 0.0:
5447 raise ValueError(f"{field_name}.params.bulk_velocity must be positive.")
5448 try:
5449 n_terms = int(params.get("n_terms", 101))
5450 except (TypeError, ValueError):
5451 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
5452 if n_terms <= 0 or n_terms % 2 == 0:
5453 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
5454 return {"bulk_velocity": bulk_velocity, "n_terms": n_terms}
5455
5456GENERATED_PROFILE_GENERATORS = {"square_duct_poiseuille"}
5457
5458def _normalize_field_slice_source(source, field_name: str) -> dict:
5459 """!
5460 @brief Validate a prescribed_flow field_slice source block.
5461 @param[in] source Source mapping from case.yml.
5462 @param[in] field_name Human-readable YAML path for diagnostics.
5463 @return Normalized source mapping.
5464 """
5465 allowed = {
5466 "type",
5467 "script",
5468 "field_file",
5469 "grid_file",
5470 "source_case",
5471 "velocity_scale",
5472 "source_block",
5473 "output_file",
5474 "slice",
5475 }
5476 unknown = sorted(set(source.keys()) - allowed)
5477 if unknown:
5478 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
5479 field_file = source.get("field_file")
5480 grid_file = source.get("grid_file")
5481 if not isinstance(field_file, str) or not field_file.strip():
5482 raise ValueError(f"{field_name}.field_file must be a non-empty path.")
5483 if not isinstance(grid_file, str) or not grid_file.strip():
5484 raise ValueError(f"{field_name}.grid_file must be a non-empty path.")
5485 if source.get("source_case") is None and source.get("velocity_scale") is None:
5486 raise ValueError(f"{field_name} requires source_case or velocity_scale.")
5487
5488 normalized = {
5489 "type": "field_slice",
5490 "field_file": field_file.strip(),
5491 "grid_file": grid_file.strip(),
5492 "slice": _normalize_field_slice_selector(source.get("slice"), f"{field_name}.slice"),
5493 }
5494 if source.get("script") is not None:
5495 script = source.get("script")
5496 if not isinstance(script, str) or not script.strip():
5497 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
5498 normalized["script"] = script.strip()
5499 if source.get("source_case") is not None:
5500 source_case = source.get("source_case")
5501 if not isinstance(source_case, str) or not source_case.strip():
5502 raise ValueError(f"{field_name}.source_case must be a non-empty path when provided.")
5503 normalized["source_case"] = source_case.strip()
5504 if source.get("velocity_scale") is not None:
5505 velocity_scale = _to_float(source.get("velocity_scale"), f"{field_name}.velocity_scale")
5506 if velocity_scale <= 0.0:
5507 raise ValueError(f"{field_name}.velocity_scale must be positive.")
5508 normalized["velocity_scale"] = velocity_scale
5509 if source.get("source_block") is not None:
5510 try:
5511 source_block = int(source.get("source_block"))
5512 except (TypeError, ValueError):
5513 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
5514 if source_block < 0:
5515 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
5516 normalized["source_block"] = source_block
5517 if source.get("output_file") is not None:
5518 output_file = source.get("output_file")
5519 if not isinstance(output_file, str) or not output_file.strip():
5520 raise ValueError(f"{field_name}.output_file must be a non-empty path when provided.")
5521 normalized["output_file"] = output_file.strip()
5522 return normalized
5523
5524def _normalize_field_slice_selector(slice_cfg, field_name: str) -> dict:
5525 """!
5526 @brief Validate the field_slice slice selector.
5527 @param[in] slice_cfg Slice selector mapping.
5528 @param[in] field_name Human-readable YAML path for diagnostics.
5529 @return Normalized selector mapping.
5530 """
5531 if not isinstance(slice_cfg, dict):
5532 raise ValueError(f"{field_name} must be a mapping.")
5533 orientation = str(slice_cfg.get("orientation", "opposite")).strip().lower()
5534 if orientation not in {"opposite", "same"}:
5535 raise ValueError(f"{field_name}.orientation must be 'opposite' or 'same'.")
5536 normal_tolerance = _to_float(slice_cfg.get("normal_tolerance", 0.99), f"{field_name}.normal_tolerance")
5537 if normal_tolerance <= 0.0 or normal_tolerance > 1.0:
5538 raise ValueError(f"{field_name}.normal_tolerance must be in the range (0, 1].")
5539
5540 if slice_cfg.get("face") is not None:
5541 unknown = sorted(set(slice_cfg.keys()) - {"face", "orientation", "normal_tolerance"})
5542 if unknown:
5543 raise ValueError(
5544 f"Unknown keys in {field_name}: {unknown}. "
5545 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
5546 )
5547 face = str(slice_cfg.get("face", "")).strip()
5548 if face.lower() not in BC_FACE_MAP:
5549 raise ValueError(f"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
5550 return {
5551 "face": BC_FACE_MAP[face.lower()],
5552 "orientation": orientation,
5553 "normal_tolerance": normal_tolerance,
5554 }
5555
5556 required = {"axis", "index", "normal"}
5557 missing = sorted(key for key in required if slice_cfg.get(key) is None)
5558 if missing:
5559 raise ValueError(f"{field_name} requires either face or axis/index/normal; missing {missing}.")
5560 unknown = sorted(set(slice_cfg.keys()) - {"axis", "index", "normal", "orientation", "normal_tolerance"})
5561 if unknown:
5562 raise ValueError(
5563 f"Unknown keys in {field_name}: {unknown}. "
5564 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
5565 )
5566 axis = str(slice_cfg.get("axis", "")).strip()
5567 axis_map = {"xi": "Xi", "eta": "Eta", "zeta": "Zeta"}
5568 if axis.lower() not in axis_map:
5569 raise ValueError(f"{field_name}.axis must be one of Xi, Eta, Zeta.")
5570 normal = str(slice_cfg.get("normal", "")).strip()
5571 if normal.lower() not in BC_FACE_MAP:
5572 raise ValueError(f"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
5573 normal = BC_FACE_MAP[normal.lower()]
5574 if normal[1:].lower() != axis.lower():
5575 raise ValueError(f"{field_name}.normal must use the same axis as {field_name}.axis.")
5576 try:
5577 index = int(slice_cfg.get("index"))
5578 except (TypeError, ValueError):
5579 raise ValueError(f"{field_name}.index must be an integer.")
5580 if index < 0:
5581 raise ValueError(f"{field_name}.index must be non-negative.")
5582 return {
5583 "axis": axis_map[axis.lower()],
5584 "index": index,
5585 "normal": normal,
5586 "orientation": orientation,
5587 "normal_tolerance": normal_tolerance,
5588 }
5589
5590def generate_square_duct_poiseuille_picslice(output_path: str, dims: tuple, params: dict,
5591 target_grid: str = None, target_block: int = 0,
5592 target_face: str = None, script: str = None,
5593 case_path: str = None) -> dict:
5594 """!
5595 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
5596 @param[in] output_path Path to write.
5597 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
5598 @param[in] params Normalized generator params.
5599 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
5600 @param[in] target_block Target block index when `target_grid` is provided.
5601 @param[in] target_face Target inlet face when `target_grid` is provided.
5602 @param[in] script Optional profile.gen-compatible script override.
5603 @param[in] case_path Current case.yml path used to anchor relative script overrides.
5604 @return Summary dictionary.
5605 """
5606 n1, n2 = tuple(dims)
5607 profilegen_script = _resolve_generator_script(script, case_path, "profile.gen")
5608 if not os.path.isfile(profilegen_script):
5609 raise ValueError(f"profile.gen script not found: {profilegen_script}")
5610 cmd = [
5611 sys.executable,
5612 profilegen_script,
5613 "square_duct_poiseuille",
5614 "--output",
5615 output_path,
5616 "--dims",
5617 str(n1),
5618 str(n2),
5619 "--bulk-velocity",
5620 str(float(params["bulk_velocity"])),
5621 "--n-terms",
5622 str(int(params["n_terms"])),
5623 ]
5624 if target_grid:
5625 cmd.extend([
5626 "--target-grid",
5627 target_grid,
5628 "--target-block",
5629 str(int(target_block)),
5630 f"--target-face={target_face}",
5631 ])
5632 result = subprocess.run(cmd, text=True, capture_output=True)
5633 if result.returncode != 0:
5634 details = (result.stderr or result.stdout or "").strip()
5635 raise ValueError(f"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
5636 try:
5637 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
5638 except (IndexError, json.JSONDecodeError) as exc:
5639 raise ValueError(f"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}") from exc
5640 summary["dims"] = tuple(summary["dims"])
5641 return summary
5642
5643def generate_field_slice_picslice(output_path: str, expected_dims: tuple, source: dict,
5644 target_grid: str, target_face: str, target_block: int,
5645 case_path: str) -> dict:
5646 """!
5647 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
5648 @param[in] output_path Path to write.
5649 @param[in] expected_dims Expected PICSLICE dimensions.
5650 @param[in] source Normalized field_slice source mapping.
5651 @param[in] target_grid Target canonical PICGRID path.
5652 @param[in] target_face Target inlet face token.
5653 @param[in] target_block Target block index.
5654 @param[in] case_path Path to current case.yml for relative source resolution.
5655 @return Summary dictionary from profile.gen.
5656 """
5657 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
5658 field_file = _resolve_case_relative_path(source["field_file"], case_dir)
5659 source_grid = _resolve_case_relative_path(source["grid_file"], case_dir)
5660 velocity_scale = _resolve_field_slice_velocity_scale(source, case_dir)
5661 profilegen_script = _resolve_generator_script(source.get("script"), case_path, "profile.gen")
5662 if not os.path.isfile(profilegen_script):
5663 raise ValueError(f"profile.gen script not found: {profilegen_script}")
5664 n1, n2 = tuple(expected_dims)
5665 slice_cfg = source["slice"]
5666 cmd = [
5667 sys.executable,
5668 profilegen_script,
5669 "field-slice",
5670 "--output",
5671 output_path,
5672 "--field-file",
5673 field_file,
5674 "--source-grid",
5675 source_grid,
5676 "--target-grid",
5677 target_grid,
5678 "--source-block",
5679 str(int(source.get("source_block", 0))),
5680 "--target-block",
5681 str(int(target_block)),
5682 f"--target-face={target_face}",
5683 "--orientation",
5684 slice_cfg["orientation"],
5685 "--normal-tolerance",
5686 str(float(slice_cfg["normal_tolerance"])),
5687 "--velocity-scale",
5688 str(float(velocity_scale)),
5689 "--expected-dims",
5690 str(n1),
5691 str(n2),
5692 ]
5693 if "face" in slice_cfg:
5694 cmd.append(f"--slice-face={slice_cfg['face']}")
5695 else:
5696 cmd.extend([
5697 "--slice-axis",
5698 slice_cfg["axis"],
5699 "--slice-index",
5700 str(int(slice_cfg["index"])),
5701 f"--slice-normal={slice_cfg['normal']}",
5702 ])
5703 result = subprocess.run(cmd, text=True, capture_output=True)
5704 if result.returncode != 0:
5705 details = (result.stderr or result.stdout or "").strip()
5706 raise ValueError(f"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
5707 try:
5708 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
5709 except (IndexError, json.JSONDecodeError) as exc:
5710 raise ValueError(f"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}") from exc
5711 summary["dims"] = tuple(summary["dims"])
5712 return summary
5713
5714def _resolve_case_relative_path(path_value: str, case_dir: str) -> str:
5715 """!
5716 @brief Resolve a path relative to the current case directory.
5717 @param[in] path_value Path from case.yml.
5718 @param[in] case_dir Current case directory.
5719 @return Absolute path.
5720 """
5721 if not isinstance(path_value, str) or not path_value.strip():
5722 raise ValueError("path value must be a non-empty string.")
5723 workspace_root = find_workspace_root(case_dir)
5724 if workspace_root:
5725 if os.path.isabs(path_value):
5726 raise ValueError(
5727 f"absolute path {path_value!r} is not allowed in workspace case configuration; "
5728 "use 'picurv inputs import --mode reference' for an explicit external reference."
5729 )
5730 resolved = os.path.abspath(os.path.join(workspace_root, path_value))
5731 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
5732 raise ValueError(f"path {path_value!r} escapes the workspace.")
5733 if resolved.endswith(".reference.yml") and os.path.isfile(resolved):
5734 pointer = read_yaml_file(resolved)
5735 external = pointer.get("picurv_external_reference") if isinstance(pointer, dict) else None
5736 if not isinstance(external, str) or not os.path.isabs(external):
5737 raise ValueError(f"Invalid external-reference descriptor: {resolved}")
5738 if not os.path.isfile(external):
5739 raise ValueError(f"Registered external input is unavailable: {external}")
5740 return external
5741 return resolved
5742 if os.path.isabs(path_value):
5743 return os.path.abspath(path_value)
5744 return os.path.abspath(os.path.join(case_dir, path_value))
5745
5746def _resolve_field_slice_velocity_scale(source: dict, case_dir: str) -> float:
5747 """!
5748 @brief Resolve field_slice dimensional velocity scale.
5749 @param[in] source Normalized field_slice source mapping.
5750 @param[in] case_dir Current case directory.
5751 @return Positive velocity scale.
5752 """
5753 if source.get("velocity_scale") is not None:
5754 return float(source["velocity_scale"])
5755 source_case = _resolve_case_relative_path(source["source_case"], case_dir)
5756 source_case_cfg = read_yaml_file(source_case)
5757 try:
5758 velocity_scale = _to_float(
5759 source_case_cfg.get("properties", {}).get("scaling", {}).get("velocity_ref"),
5760 "source_case.properties.scaling.velocity_ref",
5761 )
5762 except AttributeError as exc:
5763 raise ValueError("source_case must contain properties.scaling.velocity_ref.") from exc
5764 if velocity_scale <= 0.0:
5765 raise ValueError("source_case.properties.scaling.velocity_ref must be positive.")
5766 return velocity_scale
5767
5768def resolve_target_grid_for_field_slice(case_cfg: dict, case_path: str, run_dir: str) -> str:
5769 """!
5770 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
5771 @param[in] case_cfg Parsed current case config.
5772 @param[in] case_path Current case.yml path.
5773 @param[in] run_dir Current run/precompute directory.
5774 @return Absolute target PICGRID path.
5775 """
5776 grid_cfg = case_cfg.get("grid", {}) or {}
5777 grid_mode = grid_cfg.get("mode")
5778 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
5779 if grid_mode == "file":
5780 source_grid = grid_cfg.get("source_file")
5781 if not isinstance(source_grid, str) or not source_grid.strip():
5782 raise ValueError("grid.source_file is required for field_slice target-grid normals.")
5783 source_grid = _resolve_case_relative_path(source_grid, case_dir)
5784 return source_grid
5785 if grid_mode == "grid_gen":
5786 candidate = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
5787 if os.path.isfile(candidate):
5788 return candidate
5789 staged = os.path.join(run_dir, "inputs", "grid", "grid.run")
5790 if os.path.isfile(staged):
5791 return staged
5792 raise ValueError("field_slice requires the generated target PICGRID to exist before profile extraction.")
5793 raise ValueError(
5794 f"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
5795 )
5796
5797def resolve_target_grid_for_generated_profile(case_cfg: dict, case_path: str, run_dir: str) -> str:
5798 """!
5799 @brief Resolve an optional target canonical PICGRID for generated profile sampling.
5800 @param[in] case_cfg Parsed current case config.
5801 @param[in] case_path Current case.yml path.
5802 @param[in] run_dir Current run/precompute directory.
5803 @return Absolute target PICGRID path, or None when no canonical grid is available yet.
5804 """
5805 grid_mode = (case_cfg.get("grid", {}) or {}).get("mode")
5806 if grid_mode == "programmatic_c":
5807 return None
5808 return resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
5809
5810def write_profile_info(config_dir: str, summaries: list) -> str:
5811 """!
5812 @brief Write a profile.info summary for generated inlet profiles.
5813 @param[in] config_dir Run/precompute config directory.
5814 @param[in] summaries Generated profile summaries.
5815 @return Path to profile.info.
5816 """
5817 info_path = os.path.join(config_dir, "profile.info")
5818 os.makedirs(config_dir, exist_ok=True)
5819 with open(info_path, "w") as fout:
5820 fout.write("# PICurv generated profile summary\n")
5821 fout.write(f"profile_count = {len(summaries)}\n\n")
5822 for idx, summary in enumerate(summaries):
5823 dims = summary.get("dims", (0, 0))
5824 fout.write(f"[profile_{idx}]\n")
5825 fout.write(f"generator = {summary.get('generator')}\n")
5826 fout.write(f"block = {summary.get('block')}\n")
5827 fout.write(f"face = {summary.get('face')}\n")
5828 fout.write(f"dimensions = {dims[0]} {dims[1]}\n")
5829 if summary.get("bulk_velocity") is not None:
5830 fout.write(f"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
5831 if summary.get("n_terms") is not None:
5832 fout.write(f"n_terms = {summary.get('n_terms')}\n")
5833 fout.write(f"mean_speed = {summary.get('mean_speed'):.16e}\n")
5834 if "area_mean_speed" in summary:
5835 fout.write(f"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
5836 if "discrete_mean_speed" in summary:
5837 fout.write(f"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
5838 fout.write(f"min_speed = {summary.get('min_speed'):.16e}\n")
5839 fout.write(f"max_speed = {summary.get('max_speed'):.16e}\n")
5840 if summary.get("umax_over_ubulk") is not None:
5841 fout.write(f"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
5842 for key in (
5843 "normalization",
5844 "sampling",
5845 "area_weighted_mean_before_normalization",
5846 "area_weighted_mean_after_normalization",
5847 "total_inlet_area",
5848 "face_area_min",
5849 "face_area_max",
5850 "source_field",
5851 "source_grid",
5852 "target_grid",
5853 "source_block",
5854 "target_block",
5855 "target_face",
5856 "source_slice",
5857 "orientation",
5858 "normal_tolerance",
5859 "normal_dot",
5860 "velocity_scale",
5861 ):
5862 if key in summary:
5863 fout.write(f"{key} = {summary.get(key)}\n")
5864 fout.write(f"output_file = {summary.get('path')}\n\n")
5865 return info_path
5866
5867#: Generator flags whose values are drawn from a closed choice set. Checking them where
5868#: the case is validated turns a mid-run subprocess failure into a startup error.
5869GRID_GENERATOR_CHOICE_FLAGS = {
5870 "--cross-section": GRID_CROSS_SECTION_KINDS,
5871}
5872
5873#: Generator flags taking an ordered `kind:key=value` list whose kinds are closed.
5874GRID_GENERATOR_SPEC_FLAGS = {
5875 "--wall-j-lo": GRID_WALL_SEGMENT_KINDS,
5876 "--wall-j-hi": GRID_WALL_SEGMENT_KINDS,
5877 "--wall-j-lo-span": GRID_WALL_SEGMENT_KINDS,
5878 "--wall-j-hi-span": GRID_WALL_SEGMENT_KINDS,
5879 "--cross-section-scale": GRID_WALL_SEGMENT_KINDS,
5880 "--path": GRID_PATH_SEGMENT_KINDS,
5881 "--transforms": GRID_TRANSFORM_KINDS,
5882}
5883
5884
5885def validate_grid_generator_cli_args(cli_args, case_path: str) -> list:
5886 """!
5887 @brief Check closed-choice values inside the generator's opaque token list.
5888 @details `cli_args` is passed through to grid.gen untouched, so a misspelled geometry
5889 selector there is only discovered when the subprocess exits nonzero partway
5890 into a run. The generator remains the authority; this reports the same set
5891 earlier, and stays silent on anything it does not recognize.
5892 @param[in] cli_args Raw token list from grid.generator.cli_args.
5893 @param[in] case_path Case file path for diagnostics.
5894 @return List of error strings.
5895 """
5896 if not isinstance(cli_args, list):
5897 return []
5898 tokens = [str(token) for token in cli_args]
5899 errors = []
5900 for index, token in enumerate(tokens):
5901 values = []
5902 for follower in tokens[index + 1:]:
5903 if follower.startswith("--"):
5904 break
5905 values.append(follower)
5906 if token in GRID_GENERATOR_CHOICE_FLAGS:
5907 allowed = GRID_GENERATOR_CHOICE_FLAGS[token]
5908 for value in values[:1]:
5909 if value not in allowed:
5910 errors.append(
5911 f" {case_path}: grid.generator.cli_args {token} must be one of "
5912 f"{list(allowed)} (got '{value}')."
5913 )
5914 elif token in GRID_GENERATOR_SPEC_FLAGS:
5915 allowed = GRID_GENERATOR_SPEC_FLAGS[token]
5916 for value in values:
5917 kind = value.split(":", 1)[0]
5918 if kind not in allowed:
5919 errors.append(
5920 f" {case_path}: grid.generator.cli_args {token} entry '{value}' "
5921 f"names '{kind}', which is not one of {list(allowed)}."
5922 )
5923 return errors
5924
5925
5926def run_grid_generator(case_path: str, run_dir: str, grid_cfg: dict,
5927 case_cfg: dict = None) -> str:
5928 """!
5929 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
5930 @param[in] case_path Path to case.yml (used for relative path resolution).
5931 @param[in] run_dir Run directory path.
5932 @param[in] grid_cfg The grid config section from case.yml.
5933 @param[in] case_cfg Parsed case configuration, when available. Its reference scales
5934 are handed to the generator so the quality report can carry
5935 solver and wall units. Reporting only: the generator never scales
5936 coordinates, which validate_and_nondimensionalize_picgrid does
5937 once for every grid regardless of origin.
5938 @return Absolute path to generated dimensional PICGRID file.
5939 @throws ValueError on invalid config or generator failure.
5940 """
5941 generator = grid_cfg.get("generator", {})
5942 if not isinstance(generator, dict):
5943 raise ValueError("grid.generator must be a mapping when grid.mode is 'grid_gen'.")
5944
5945 case_dir = os.path.dirname(os.path.abspath(case_path))
5946 gridgen_script = generator.get("script", os.path.join(GENERATORS_PATH, "grid.gen"))
5947 if generator.get("script"):
5948 gridgen_script = _resolve_case_relative_path(gridgen_script, case_dir)
5949 else:
5950 gridgen_script = os.path.abspath(gridgen_script)
5951 if not os.path.isfile(gridgen_script):
5952 raise ValueError(f"grid.gen script not found: {gridgen_script}")
5953
5954 config_file = generator.get("config_file")
5955 if not config_file:
5956 raise ValueError("grid.generator.config_file is required when grid.mode is 'grid_gen'.")
5957 config_file = _resolve_case_relative_path(config_file, case_dir)
5958 if not os.path.isfile(config_file):
5959 raise ValueError(f"grid.generator.config_file not found: {config_file}")
5960
5961 output_file = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
5962 os.makedirs(os.path.dirname(output_file), exist_ok=True)
5963
5964 grid_type = generator.get("grid_type")
5965 cli_args = generator.get("cli_args", [])
5966 if cli_args is None:
5967 cli_args = []
5968 if not isinstance(cli_args, list):
5969 raise ValueError("grid.generator.cli_args must be a list of CLI tokens.")
5970
5971 cmd = [sys.executable, gridgen_script, "-c", config_file]
5972 if grid_type:
5973 cmd.append(str(grid_type))
5974 cmd.extend([str(token) for token in cli_args])
5975 cmd.extend(["--output", output_file])
5976
5977 # Destinations are PICurv's, not the user's. Inspection material is produced
5978 # unconditionally so a published asset is always something the user can look at,
5979 # rather than only when a configuration file happened to name an output path.
5980 vts_file = os.path.abspath(
5981 os.path.join(run_dir, "output", "visualization", "precompute", "grid.vts")
5982 )
5983 os.makedirs(os.path.dirname(vts_file), exist_ok=True)
5984 cmd.extend(["--vts", vts_file])
5985
5986 stats_file = os.path.abspath(
5987 os.path.join(run_dir, "output", "analysis", "metrics", "grid.info")
5988 )
5989 os.makedirs(os.path.dirname(stats_file), exist_ok=True)
5990 cmd.extend(["--stats-file", stats_file])
5991
5992 # The case already knows these; the generator would otherwise report a grid in metres
5993 # with no way to say what it becomes downstream. Resolution is best-effort: a case
5994 # that omits them still gets its grid, just without the derived-unit sections.
5995 if case_cfg:
5996 try:
5997 scaling = resolve_fluid_scaling(case_cfg)
5998 except (KeyError, ValueError, TypeError):
5999 scaling = None
6000 if scaling:
6001 cmd.extend(["--length-ref", repr(scaling["length_ref"]),
6002 "--velocity-ref", repr(scaling["velocity_ref"]),
6003 "--nu", repr(scaling["physical_kinematic_viscosity"])])
6004
6005 print(f"[INFO] Grid generator command: {' '.join(cmd)}")
6006 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
6007 if result.returncode != 0:
6008 stderr = (result.stderr or "").strip()
6009 stdout = (result.stdout or "").strip()
6010 details = stderr if stderr else stdout
6011 raise ValueError(
6012 f"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
6013 )
6014 if result.stdout:
6015 print(result.stdout.strip())
6016 if result.stderr:
6017 print(result.stderr.strip())
6018
6019 if not os.path.isfile(output_file):
6020 raise ValueError(f"grid.gen did not produce expected output file: {output_file}")
6021
6022 return output_file
6023
6024
6025BC_FACE_MAP = {
6026 "-xi": "-Xi",
6027 "+xi": "+Xi",
6028 "-eta": "-Eta",
6029 "+eta": "+Eta",
6030 "-zeta": "-Zeta",
6031 "+zeta": "+Zeta",
6032}
6033
6034# Canonical BC selector maps. Add new values only with matching C parser/factory support.
6035BC_TYPE_MAP = {
6036 "wall": "WALL",
6037 "symmetry": "SYMMETRY",
6038 "inlet": "INLET",
6039 "outlet": "OUTLET",
6040 "periodic": "PERIODIC",
6041}
6042
6043BC_HANDLER_SPECS = {
6044 # Only handlers that are implemented end-to-end in current C path are allowed.
6045 "noslip": {
6046 "types": {"WALL"},
6047 "required_params": set(),
6048 "optional_params": set(),
6049 },
6050 "constant_velocity": {
6051 "types": {"INLET"},
6052 "required_params": {"vx", "vy", "vz"},
6053 "optional_params": set(),
6054 },
6055 "conservation": {
6056 "types": {"OUTLET"},
6057 "required_params": set(),
6058 "optional_params": set(),
6059 },
6060 "parabolic": {
6061 "types": {"INLET"},
6062 "required_params": {"v_max"},
6063 "optional_params": set(),
6064 },
6065 "prescribed_flow": {
6066 "types": {"INLET"},
6067 "required_params": {"source"},
6068 "optional_params": set(),
6069 },
6070 "geometric": {
6071 "types": {"PERIODIC"},
6072 "required_params": set(),
6073 "optional_params": set(),
6074 },
6075 "constant_flux": {
6076 "types": {"PERIODIC"},
6077 "required_params": {"target_flux"},
6078 "optional_params": {"enforce_seam_flux", "apply_trim"},
6079 },
6080 # Derives its own target from the initial condition, so it takes no target_flux.
6081 "initial_flux": {
6082 "types": {"PERIODIC"},
6083 "required_params": set(),
6084 "optional_params": {"enforce_seam_flux", "apply_trim"},
6085 },
6086}
6087
6088_NUMERIC_BC_PARAMS = {"vx", "vy", "vz", "v_max", "target_flux"}
6089_BOOL_BC_PARAMS = {"enforce_seam_flux", "apply_trim"}
6090
6091# Deprecated BC param spellings -> canonical name. `apply_trim` said that
6092# something was trimmed but not what or why; `enforce_seam_flux` names the
6093# quantity it holds on target. Both are accepted; bcs.run carries the canonical
6094# name, and the C side also falls back to the old spelling for archived files.
6095_DEPRECATED_BC_PARAM_ALIASES = {"apply_trim": "enforce_seam_flux"}
6096
6097def _normalize_prescribed_flow_source(source, field_name: str) -> dict:
6098 """!
6099 @brief Validate the structured source block for prescribed_flow BCs.
6100 @param[in] source Source mapping from case.yml.
6101 @param[in] field_name Human-readable YAML path for diagnostics.
6102 @return Normalized source mapping.
6103 @throws ValueError on invalid source contract.
6104 """
6105 if not isinstance(source, dict):
6106 raise ValueError(f"{field_name} must be a mapping with type: file, generated, or field_slice.")
6107 source_type = str(source.get("type", "")).strip().lower()
6108 if source_type == "file":
6109 path = source.get("path")
6110 if not isinstance(path, str) or not path.strip():
6111 raise ValueError(f"{field_name}.path must be a non-empty file path.")
6112 unknown = sorted(set(source.keys()) - {"type", "path"})
6113 if unknown:
6114 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
6115 return {"type": "file", "path": path.strip()}
6116
6117 if source_type == "generated":
6118 generator = str(source.get("generator", "")).strip().lower()
6119 if generator not in GENERATED_PROFILE_GENERATORS:
6120 raise ValueError(
6121 f"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
6122 f"(got '{source.get('generator')}')."
6123 )
6124 unknown = sorted(set(source.keys()) - {"type", "generator", "script", "output_file", "params"})
6125 if unknown:
6126 raise ValueError(
6127 f"Unknown keys in {field_name}: {unknown}. "
6128 "Allowed: ['generator', 'output_file', 'params', 'script', 'type']."
6129 )
6130 normalized = {
6131 "type": "generated",
6132 "generator": generator,
6133 "params": _normalize_square_duct_poiseuille_params(source.get("params", {}), field_name),
6134 }
6135 output_file = source.get("output_file")
6136 if output_file is not None:
6137 if not isinstance(output_file, str) or not output_file.strip():
6138 raise ValueError(f"{field_name}.output_file must be a non-empty path when provided.")
6139 normalized["output_file"] = output_file.strip()
6140 script = source.get("script")
6141 if script is not None:
6142 if not isinstance(script, str) or not script.strip():
6143 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
6144 normalized["script"] = script.strip()
6145 return normalized
6146
6147 if source_type == "field_slice":
6148 return _normalize_field_slice_source(source, field_name)
6149
6150 raise ValueError(f"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
6151
6152def _bc_profile_expected_dims(face: str, block_dims: tuple) -> tuple:
6153 """!
6154 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
6155 @param[in] face Canonical BC face token.
6156 @param[in] block_dims (IM, JM, KM) node counts.
6157 @return (n1, n2) dimensions in profile storage order.
6158 """
6159 im, jm, km = block_dims
6160 if min(im, jm, km) < 2:
6161 raise ValueError(
6162 f"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
6163 )
6164 if face in {"-Xi", "+Xi"}:
6165 return (km - 1, jm - 1)
6166 if face in {"-Eta", "+Eta"}:
6167 return (km - 1, im - 1)
6168 if face in {"-Zeta", "+Zeta"}:
6169 return (jm - 1, im - 1)
6170 raise ValueError(f"Unsupported face '{face}' for prescribed_flow profile dimensions.")
6171
6172def resolve_grid_block_dimensions_for_profiles(case_cfg: dict, case_path: str, run_dir: str = None) -> list:
6173 """!
6174 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
6175 @param[in] case_cfg Parsed case.yml configuration.
6176 @param[in] case_path Path to case.yml for relative path resolution.
6177 @param[in] run_dir Current run directory, used for optional generated grid outputs.
6178 @return List of (IM, JM, KM) node-count tuples.
6179 @throws ValueError when dimensions cannot be resolved.
6180 """
6181 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
6182 grid_cfg = case_cfg.get("grid", {})
6183 grid_mode = grid_cfg.get("mode")
6184 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
6185
6186 if grid_mode == "programmatic_c":
6187 settings = grid_cfg.get("programmatic_settings", {})
6188 dims_by_axis = []
6189 for key in ("im", "jm", "km"):
6190 raw = settings.get(key)
6191 if raw is None:
6192 raise ValueError(f"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
6193 if isinstance(raw, list):
6194 if len(raw) != num_blocks:
6195 raise ValueError(
6196 f"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
6197 )
6198 values = raw
6199 else:
6200 values = [raw] * num_blocks
6201 try:
6202 values = [int(v) + 1 for v in values]
6203 except (TypeError, ValueError):
6204 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
6205 if any(v <= 1 for v in values):
6206 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
6207 dims_by_axis.append(values)
6208 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
6209
6210 if grid_mode == "file":
6211 source_grid = grid_cfg.get("source_file")
6212 if not isinstance(source_grid, str) or not source_grid.strip():
6213 raise ValueError("grid.source_file is required for file-grid prescribed_flow profile validation.")
6214 source_grid = _resolve_case_relative_path(source_grid, case_dir)
6215 return read_picgrid_header_dimensions(source_grid, expected_nblk=num_blocks)
6216
6217 if grid_mode == "grid_gen":
6218 candidates = []
6219 if run_dir:
6220 candidates.append(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
6221 candidates.append(os.path.join(run_dir, "inputs", "grid", "grid.run"))
6222 for candidate in candidates:
6223 if os.path.isfile(candidate):
6224 return read_picgrid_header_dimensions(candidate, expected_nblk=num_blocks)
6225 raise ValueError(
6226 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
6227 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
6228 )
6229
6230 raise ValueError(f"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
6231
6232def materialize_generated_prescribed_flow_profiles(run_dir: str, case_cfg: dict, case_path: str,
6233 profile_grid_dims: list = None) -> list:
6234 """!
6235 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
6236 @param[in] run_dir Run/precompute directory root.
6237 @param[in] case_cfg Parsed case.yml.
6238 @param[in] case_path Path to case.yml for relative grid/source resolution.
6239 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
6240 @return List of generated profile summaries.
6241 """
6242 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
6243 if not any(
6244 bc.get("handler") == "prescribed_flow"
6245 and ((bc.get("params") or {}).get("source") or {}).get("type") in PRESCRIBED_FLOW_SOURCE_TYPES[1:]
6246 for block in prepared_blocks for bc in block
6247 ):
6248 return []
6249 if profile_grid_dims is None:
6250 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
6251
6252 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
6253 target_grid = None
6254 generated_target_grid = None
6255 summaries = []
6256 for block_idx, block in enumerate(prepared_blocks):
6257 for bc in block:
6258 if bc.get("handler") != "prescribed_flow":
6259 continue
6260 source = (bc.get("params") or {}).get("source", {})
6261 if source.get("type") not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
6262 continue
6263 face = bc["face"]
6264 dims = _bc_profile_expected_dims(face, profile_grid_dims[block_idx])
6265 face_token = _face_artifact_token(face)
6266 suffix = "generated" if source.get("type") == "generated" else "sliced"
6267 output_path = os.path.join(
6268 profile_dir,
6269 f"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice",
6270 )
6271 if source.get("type") == "generated" and source["generator"] == "square_duct_poiseuille":
6272 if generated_target_grid is None:
6273 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
6275 output_path,
6276 dims,
6277 source["params"],
6278 target_grid=generated_target_grid,
6279 target_block=block_idx,
6280 target_face=face,
6281 script=source.get("script"),
6282 case_path=case_path,
6283 )
6284 elif source.get("type") == "generated":
6285 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
6286 else:
6287 if target_grid is None:
6288 target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
6290 output_path,
6291 dims,
6292 source,
6293 target_grid,
6294 face,
6295 block_idx,
6296 case_path,
6297 )
6298 summary.update({"block": block_idx, "face": face})
6299 summaries.append(summary)
6300 print(
6301 f"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
6302 f"{os.path.relpath(output_path)} dims={summary['dims']}"
6303 )
6304
6305 if summaries:
6306 info_path = write_profile_info(profile_dir, summaries)
6307 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
6308 return summaries
6309
6310def _to_float(value, field_name: str) -> float:
6311 """!
6312 @brief Convert a YAML scalar to float with a clear error message.
6313 @param[in] value Argument passed to `_to_float()`.
6314 @param[in] field_name Argument passed to `_to_float()`.
6315 @return Value returned by `_to_float()`.
6316 """
6317 try:
6318 return float(value)
6319 except (TypeError, ValueError):
6320 raise ValueError(f"'{field_name}' must be numeric (got {value!r}).")
6321
6322
6323def _to_finite_float(value, field_name: str) -> float:
6324 """!
6325 @brief Convert a non-boolean YAML scalar to a finite float.
6326 @param[in] value Raw YAML scalar.
6327 @param[in] field_name User-facing configuration path.
6328 @return Finite floating-point value.
6329 """
6330 if isinstance(value, bool):
6331 raise ValueError(f"'{field_name}' must be numeric, not boolean.")
6332 parsed = _to_float(value, field_name)
6333 if not math.isfinite(parsed):
6334 raise ValueError(f"'{field_name}' must be finite (got {value!r}).")
6335 return parsed
6336
6337def _to_bool(value, field_name: str) -> bool:
6338 """!
6339 @brief Convert a YAML scalar/string to bool with a clear error message.
6340 @param[in] value Argument passed to `_to_bool()`.
6341 @param[in] field_name Argument passed to `_to_bool()`.
6342 @return Value returned by `_to_bool()`.
6343 """
6344 if isinstance(value, bool):
6345 return value
6346 if isinstance(value, str):
6347 raw = value.strip().lower()
6348 if raw in {"true", "1", "yes"}:
6349 return True
6350 if raw in {"false", "0", "no"}:
6351 return False
6352 raise ValueError(f"'{field_name}' must be boolean (got {value!r}).")
6353
6354def normalize_boundary_conditions_layout(all_blocks_bcs, num_blocks: int):
6355 """!
6356 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
6357 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
6358 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
6359 @return Value returned by `normalize_boundary_conditions_layout()`.
6360 """
6361 if not all_blocks_bcs:
6362 raise ValueError("The 'boundary_conditions' section in case.yml is empty.")
6363
6364 is_simple_list = isinstance(all_blocks_bcs[0], dict)
6365 if num_blocks == 1 and is_simple_list:
6366 all_blocks_bcs = [all_blocks_bcs]
6367 elif is_simple_list and num_blocks > 1:
6368 raise ValueError(
6369 f"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
6370 "Use a list-of-lists, one inner list per block."
6371 )
6372
6373 if len(all_blocks_bcs) != num_blocks:
6374 raise ValueError(
6375 f"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
6376 )
6377 return all_blocks_bcs
6378
6379def _les_periodic_axes(case_cfg: dict) -> set:
6380 """!
6381 @brief Reports which logical axes a case declares periodic on both faces.
6382 @param[in] case_cfg Parsed case.yml mapping.
6383 @return Set of axis letters drawn from {'i', 'j', 'k'}.
6384 """
6385 axis_faces = {"i": ("-Xi", "+Xi"), "j": ("-Eta", "+Eta"), "k": ("-Zeta", "+Zeta")}
6386 declared = {}
6387 entries = case_cfg.get("boundary_conditions") or []
6388 if entries and isinstance(entries[0], list):
6389 entries = entries[0]
6390 for entry in entries:
6391 if isinstance(entry, dict):
6392 declared[str(entry.get("face"))] = str(entry.get("type", "")).upper()
6393 return {
6394 axis for axis, faces in axis_faces.items()
6395 if all(declared.get(face) == "PERIODIC" for face in faces)
6396 }
6397
6398
6399def validate_wall_model_pairing(case_cfg: dict, les_cfg, rans_cfg, wall_cfg,
6400 case_path: str, errors: list, warnings: list):
6401 """!
6402 @brief Rejects wall-model selections that no turbulence treatment can support.
6403
6404 A wall model replaces the near-wall flow with an analytic profile so that the
6405 boundary layer need not be resolved. That only means something if the unresolved
6406 motions are modelled somewhere. Three combinations cannot be, and each fails here
6407 rather than after the mesh is built.
6408
6409 @param case_cfg Parsed case configuration, read for the Reynolds number.
6410 @param les_cfg `models.physics.turbulence.les`, or None.
6411 @param rans_cfg `models.physics.turbulence.rans`, or None.
6412 @param wall_cfg `models.physics.turbulence.wall_function`, or None.
6413 @param case_path Case path, for message prefixes.
6414 @param errors Collected blocking messages, appended to.
6415 @param warnings Collected advisory messages, appended to.
6416 """
6417 if not isinstance(wall_cfg, dict):
6418 return
6419 if not bool(wall_cfg.get('enabled', False)):
6420 return
6421
6422 try:
6423 model = normalize_wall_function_model(wall_cfg.get('model', 'log_law'))
6424 except ValueError:
6425 return # The selector's own validation already reported this.
6426
6427 les_on = isinstance(les_cfg, dict) and bool(les_cfg.get('enabled', False)) \
6428 and str(les_cfg.get('model', 'dynamic_smagorinsky')).strip().lower() != 'none'
6429 rans_on = isinstance(rans_cfg, dict) and bool(rans_cfg.get('enabled', False))
6430
6431 # A wall model with nothing to sit on. The convective scheme here is QUICK, whose
6432 # dissipation is linear and upwind-biased; it is not a limiter-based scheme whose
6433 # truncation error stands in for a subgrid stress, so there is no implicit LES to
6434 # appeal to.
6435 if not les_on and not rans_on:
6436 errors.append(
6437 f" {case_path}: models.physics.turbulence.wall_function is enabled with no "
6438 "turbulence model. A wall model supplies the stress of a boundary layer it "
6439 "does not resolve, which needs the unresolved motions modelled somewhere. "
6440 "This solver has no implicit-LES scheme to supply that - its convection is "
6441 "QUICK, whose numerical dissipation is not a subgrid model - so enable "
6442 "models.physics.turbulence.les, or resolve the wall and disable the wall "
6443 "function.")
6444
6445 # Cabot carries a mixing-length eddy viscosity in the wall layer, which is itself a
6446 # RANS closure; nesting it inside a RANS model is two closures for one layer with no
6447 # defined matching between them. Werner-Wengle applies its power law to an
6448 # instantaneous filtered velocity, which is an LES construct and has no standing as a
6449 # RANS wall function.
6450 if rans_on and model == 3:
6451 errors.append(
6452 f" {case_path}: models.physics.turbulence.wall_function.model 'cabot' cannot "
6453 "be used with RANS. Cabot solves the wall layer with its own mixing-length "
6454 "eddy viscosity, so under a RANS model the near-wall layer would carry two "
6455 "turbulence closures with no matching between them. Use 'log_law' with RANS.")
6456 if rans_on and model == 2:
6457 errors.append(
6458 f" {case_path}: models.physics.turbulence.wall_function.model 'werner' cannot "
6459 "be used with RANS. Werner-Wengle applies its power law to the instantaneous "
6460 "filtered velocity, which is a large-eddy quantity; a RANS field is already "
6461 "averaged and wants a wall law derived for the mean profile. Use 'log_law' "
6462 "with RANS.")
6463
6464 # A wall law describes a turbulent boundary layer. Below transition there is no
6465 # inertial region for it to stand on, and it would impose a profile the flow does not
6466 # have. The threshold is deliberately far below any transitional value, so that it
6467 # only catches cases that are unambiguously laminar.
6468 reynolds = _case_reynolds_number(case_cfg)
6469 if reynolds is not None and reynolds < 1000.0:
6470 errors.append(
6471 f" {case_path}: models.physics.turbulence.wall_function is enabled at "
6472 f"Reynolds number {reynolds:g}, which is laminar. The log law and the "
6473 "Werner-Wengle power law both describe a turbulent boundary layer; at this "
6474 "Reynolds number there is no inertial region for either to represent, and "
6475 "the model would impose a profile the flow does not have. Resolve the wall "
6476 "instead.")
6477
6478
6479def _case_reynolds_number(case_cfg: dict):
6480 """!
6481 @brief Reynolds number implied by a case's scaling and fluid properties.
6482 @param case_cfg Parsed case configuration.
6483 @return The Reynolds number, or None when the inputs are absent or unusable.
6484 """
6485 try:
6486 props = case_cfg.get('properties', {}) or {}
6487 scaling = props.get('scaling', {}) or {}
6488 fluid = props.get('fluid', {}) or {}
6489 density = float(fluid.get('density'))
6490 viscosity = float(fluid.get('viscosity'))
6491 length_ref = float(scaling.get('length_ref'))
6492 velocity_ref = float(scaling.get('velocity_ref'))
6493 except (TypeError, ValueError):
6494 return None
6495 if viscosity <= 0.0:
6496 return None
6497 return density * velocity_ref * length_ref / viscosity
6498
6499
6500def validate_les_configuration(case_cfg: dict, les_cfg: dict, case_path: str,
6501 errors: list, warnings: list):
6502 """!
6503 @brief Checks the structured LES block for values the closure cannot honour.
6504 @param[in] case_cfg Parsed case.yml mapping, used to read declared periodicity.
6505 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
6506 @param[in] case_path Case file path used to prefix diagnostics.
6507 @param[out] errors List collecting blocking validation failures.
6508 @param[out] warnings List collecting advisory messages.
6509 @return None; findings are appended to `errors` and `warnings`.
6510 """
6511 def _numeric(container, key, path, minimum=None, exclusive_minimum=None):
6512 """!
6513 @brief Reads one numeric key and records a range or type failure against it.
6514 @param[in] container Mapping holding the key.
6515 @param[in] key Key to read; absent keys are accepted silently.
6516 @param[in] path Dotted key path used in diagnostics.
6517 @param[in] minimum Inclusive lower bound, or None to impose none.
6518 @param[in] exclusive_minimum Exclusive lower bound, or None to impose none.
6519 @return The parsed value, or None when the key is absent or unparseable.
6520 """
6521 if key not in container:
6522 return None
6523 try:
6524 value = float(container[key])
6525 except (TypeError, ValueError):
6526 errors.append(f" {case_path}: {path} must be numeric.")
6527 return None
6528 if minimum is not None and value < minimum:
6529 errors.append(f" {case_path}: {path} must be at least {minimum}.")
6530 if exclusive_minimum is not None and value <= exclusive_minimum:
6531 errors.append(f" {case_path}: {path} must be greater than {exclusive_minimum}.")
6532 return value
6533
6534 if 'enabled' in les_cfg and not isinstance(les_cfg['enabled'], bool):
6535 errors.append(f" {case_path}: models.physics.turbulence.les.enabled must be true or false.")
6536
6537 _numeric(les_cfg, 'constant_cs', "models.physics.turbulence.les.constant_cs", minimum=0.0)
6538
6539 if 'dynamic_frequency' in les_cfg:
6540 try:
6541 if int(les_cfg['dynamic_frequency']) <= 0:
6542 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
6543 except (TypeError, ValueError):
6544 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
6545
6546 for key, normalizer in (('filter_width', normalize_les_filter_width),):
6547 if key in les_cfg:
6548 try:
6549 normalizer(les_cfg[key])
6550 except ValueError as exc:
6551 errors.append(f" {case_path}: {exc}")
6552
6553 periodic = _les_periodic_axes(case_cfg)
6554
6555 test_filter = les_cfg.get('test_filter')
6556 if test_filter is not None:
6557 if not isinstance(test_filter, dict):
6558 test_filter = {'kernel': test_filter}
6559 if 'kernel' in test_filter:
6560 try:
6561 kernel = normalize_les_test_filter(test_filter['kernel'])
6562 except ValueError as exc:
6563 errors.append(f" {case_path}: {exc}")
6564 else:
6565 # The Simpson stencil collapses onto the central eta-plane, which is
6566 # only a valid average when xi and zeta are homogeneous.
6567 if kernel == 1 and not {"i", "k"} <= periodic:
6568 errors.append(
6569 f" {case_path}: models.physics.turbulence.les.test_filter.kernel "
6570 "'simpson_ik' assumes the xi and zeta directions are homogeneous, but "
6571 "this case does not declare both of them PERIODIC. Use "
6572 "'volume_weighted_box' instead."
6573 )
6574 # A test filter no wider than the grid filter leaves the dynamic procedure with
6575 # nothing to measure, because both terms of the model tensor coincide.
6576 _numeric(test_filter, 'width_ratio',
6577 "models.physics.turbulence.les.test_filter.width_ratio", exclusive_minimum=1.0)
6578
6579 averaging = les_cfg.get('averaging')
6580 if averaging is not None:
6581 if not isinstance(averaging, dict):
6582 averaging = {'mode': averaging}
6583 mode = None
6584 if 'mode' in averaging:
6585 try:
6586 mode = normalize_les_averaging_mode(averaging['mode'])
6587 except ValueError as exc:
6588 errors.append(f" {case_path}: {exc}")
6589 directions = None
6590 if 'directions' in averaging:
6591 try:
6592 directions = normalize_les_averaging_directions(averaging['directions'])
6593 except ValueError as exc:
6594 errors.append(f" {case_path}: {exc}")
6595 else:
6596 if not directions:
6597 errors.append(
6598 f" {case_path}: models.physics.turbulence.les.averaging.directions "
6599 "cannot be empty; omit the key to use the periodic axes."
6600 )
6601 if mode is not None and mode != 1:
6602 errors.append(
6603 f" {case_path}: models.physics.turbulence.les.averaging.directions "
6604 "applies only to mode 'homogeneous'; local and global averaging choose "
6605 "their own directions."
6606 )
6607 for axis in directions:
6608 if axis not in periodic:
6609 warnings.append(
6610 f"{case_path}: models.physics.turbulence.les.averaging.directions "
6611 f"names '{axis}', which this case does not declare PERIODIC. "
6612 "Averaging assumes the flow is statistically homogeneous there."
6613 )
6614 if mode == 1 and directions is None and not periodic:
6615 errors.append(
6616 f" {case_path}: models.physics.turbulence.les.averaging.mode 'homogeneous' "
6617 "derives its directions from the periodic boundary pairs, and this case declares "
6618 "none. Name the directions explicitly or use 'local'."
6619 )
6620
6621 clipping = les_cfg.get('clipping')
6622 if clipping is not None:
6623 if not isinstance(clipping, dict):
6624 clipping = {'mode': clipping}
6625 mode = None
6626 if 'mode' in clipping:
6627 try:
6628 mode = normalize_les_clip_mode(clipping['mode'])
6629 except ValueError as exc:
6630 errors.append(f" {case_path}: {exc}")
6631 _numeric(clipping, 'max_cs', "models.physics.turbulence.les.clipping.max_cs", minimum=0.0)
6632 _numeric(clipping, 'min_viscosity_ratio',
6633 "models.physics.turbulence.les.clipping.min_viscosity_ratio", minimum=0.0)
6634 if 'max_cs' in clipping and mode is not None and mode != 0:
6635 errors.append(
6636 f" {case_path}: models.physics.turbulence.les.clipping.max_cs applies only to "
6637 "mode 'clamp'; 'positive' and 'signed' impose no upper bound. Remove the key or "
6638 "select mode 'clamp'."
6639 )
6640
6641 diagnostics = les_cfg.get('diagnostics')
6642 if isinstance(diagnostics, dict):
6643 if 'cadence' in diagnostics:
6644 try:
6645 if int(diagnostics['cadence']) <= 0:
6646 errors.append(
6647 f" {case_path}: models.physics.turbulence.les.diagnostics.cadence must be positive."
6648 )
6649 except (TypeError, ValueError):
6650 errors.append(
6651 f" {case_path}: models.physics.turbulence.les.diagnostics.cadence must be an integer."
6652 )
6653 _numeric(diagnostics, 'yoshizawa_ci',
6654 "models.physics.turbulence.les.diagnostics.yoshizawa_ci", minimum=0.0)
6655
6656 # The dynamic procedure's controls have no meaning for a prescribed coefficient.
6657 # Only an explicitly named constant model triggers this: a template that documents
6658 # every key while LES is switched off should not be rejected on an inferred default.
6659 try:
6660 model_code = normalize_les_model(les_cfg['model']) if 'model' in les_cfg else None
6661 except ValueError:
6662 model_code = None
6663 if model_code == 1 and les_cfg.get('enabled', True):
6664 for key in ('filter_width', 'test_filter', 'averaging', 'clipping'):
6665 if key in les_cfg:
6666 errors.append(
6667 f" {case_path}: models.physics.turbulence.les.{key} configures the dynamic "
6668 "procedure and cannot be used with model 'constant_smagorinsky'."
6669 )
6670
6671
6673 """!
6674 @brief Validate BC entries against currently supported C-side handlers/types and
6675 @details return normalized entries ready for bcs.run generation.
6676 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
6677 @return Value returned by `validate_and_prepare_boundary_conditions()`.
6678 """
6679 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
6680 scales = case_cfg.get('properties', {}).get('scaling', {})
6681 L_ref = _to_float(scales.get('length_ref'), "properties.scaling.length_ref")
6682 U_ref = _to_float(scales.get('velocity_ref'), "properties.scaling.velocity_ref")
6683 if U_ref == 0.0:
6684 raise ValueError("properties.scaling.velocity_ref must be non-zero for non-dimensionalization.")
6685 if L_ref == 0.0:
6686 raise ValueError("properties.scaling.length_ref must be non-zero for non-dimensionalization.")
6687
6688 all_blocks_bcs = normalize_boundary_conditions_layout(case_cfg.get('boundary_conditions', []), num_blocks)
6689 prepared_blocks = []
6690
6691 expected_faces = {"-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta"}
6692 axis_pairs = [("-Xi", "+Xi"), ("-Eta", "+Eta"), ("-Zeta", "+Zeta")]
6693
6694 for bi, block_bcs in enumerate(all_blocks_bcs):
6695 if not isinstance(block_bcs, list):
6696 raise ValueError(f"boundary_conditions[{bi}] must be a list of face configs.")
6697
6698 prepared_block = []
6699 seen_faces = {}
6700
6701 for idx, bc in enumerate(block_bcs):
6702 if not isinstance(bc, dict):
6703 raise ValueError(f"boundary_conditions[{bi}][{idx}] must be a mapping.")
6704
6705 for req in ("face", "type", "handler"):
6706 if req not in bc:
6707 raise ValueError(f"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
6708
6709 face_raw = str(bc["face"]).strip()
6710 face_key = face_raw.lower()
6711 face = BC_FACE_MAP.get(face_key)
6712 if face is None:
6713 raise ValueError(
6714 f"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
6715 f"Supported: {sorted(expected_faces)}."
6716 )
6717 if face in seen_faces:
6718 raise ValueError(f"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
6719 seen_faces[face] = idx
6720
6721 bc_type_raw = str(bc["type"]).strip()
6722 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
6723 if bc_type is None:
6724 raise ValueError(
6725 f"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
6726 f"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
6727 )
6728
6729 handler = str(bc["handler"]).strip().lower()
6730 handler_spec = BC_HANDLER_SPECS.get(handler)
6731 if handler_spec is None:
6732 raise ValueError(
6733 f"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
6734 f"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
6735 )
6736 if bc_type not in handler_spec["types"]:
6737 raise ValueError(
6738 f"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
6739 )
6740
6741 params = bc.get("params", {})
6742 if params is None:
6743 params = {}
6744 if not isinstance(params, dict):
6745 raise ValueError(f"'params' for block {bi}, face {face} must be a mapping.")
6746
6747 # Reject unsupported older structured keys explicitly.
6748 if "vector" in params or "velocity" in params:
6749 raise ValueError(
6750 f"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
6751 "Use scalar keys 'vx', 'vy', 'vz'."
6752 )
6753
6754 required = handler_spec["required_params"]
6755 optional = handler_spec["optional_params"]
6756 allowed = required | optional
6757
6758 missing = sorted(required - set(params.keys()))
6759 if missing:
6760 raise ValueError(
6761 f"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
6762 )
6763 unknown = sorted(set(params.keys()) - allowed)
6764 if unknown:
6765 raise ValueError(
6766 f"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
6767 f"Allowed: {sorted(allowed)}."
6768 )
6769
6770 converted_params = {}
6771 for key, value in params.items():
6772 if key in _NUMERIC_BC_PARAMS:
6773 numeric = _to_float(value, f"boundary_conditions[{bi}][{idx}].params.{key}")
6774 if key in {"vx", "vy", "vz", "v_max"}:
6775 converted_params[key] = numeric / U_ref
6776 elif key == "target_flux":
6777 converted_params[key] = numeric / (U_ref * (L_ref ** 2))
6778 elif key in _BOOL_BC_PARAMS:
6779 canonical = _DEPRECATED_BC_PARAM_ALIASES.get(key, key)
6780 if canonical != key:
6781 if canonical in params:
6782 raise ValueError(
6783 f"boundary_conditions[{bi}][{idx}].params sets both '{key}' and its "
6784 f"replacement '{canonical}'. Use '{canonical}' only."
6785 )
6786 print(
6787 f"[WARNING] boundary_conditions[{bi}][{idx}].params.{key} is deprecated; "
6788 f"use '{canonical}'. It enables the local seam-flux correction on the "
6789 "periodic boundary plane, on top of the body force that sustains the bulk "
6790 "flow. See docs/pages/54_Geometric_Periodic_Boundaries.md.",
6791 file=sys.stderr,
6792 )
6793 converted_params[canonical] = _to_bool(
6794 value, f"boundary_conditions[{bi}][{idx}].params.{key}")
6795 elif handler == "prescribed_flow" and key == "source":
6796 converted_params[key] = _normalize_prescribed_flow_source(
6797 value, f"boundary_conditions[{bi}][{idx}].params.source"
6798 )
6799 else:
6800 # Defensive fallback; should not happen due unknown-key gate above.
6801 converted_params[key] = value
6802
6803 prepared_block.append({
6804 "face": face,
6805 "type": bc_type,
6806 "handler": handler,
6807 "params": converted_params,
6808 })
6809
6810 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
6811 if missing_faces:
6812 raise ValueError(
6813 f"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
6814 "Provide all six faces explicitly."
6815 )
6816
6817 # Pairwise periodic consistency checks.
6818 face_map = {entry["face"]: entry for entry in prepared_block}
6819 for neg_face, pos_face in axis_pairs:
6820 neg = face_map[neg_face]
6821 pos = face_map[pos_face]
6822 neg_periodic = (neg["type"] == "PERIODIC")
6823 pos_periodic = (pos["type"] == "PERIODIC")
6824 if neg_periodic != pos_periodic:
6825 raise ValueError(
6826 f"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
6827 )
6828
6829 driven_handlers = {"constant_flux", "initial_flux"}
6830 if (neg["handler"] in driven_handlers) or (pos["handler"] in driven_handlers):
6831 if neg["handler"] != pos["handler"]:
6832 raise ValueError(
6833 f"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
6834 )
6835 if not (neg_periodic and pos_periodic):
6836 raise ValueError(
6837 f"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
6838 )
6839
6840 prepared_blocks.append(prepared_block)
6841
6842 return prepared_blocks
6843
6844
6845def _schema_path_text(path: tuple) -> str:
6846 """!
6847 @brief Render an internal schema path tuple as a user-facing YAML path.
6848 @param[in] path Internal path tuple.
6849 @return Dotted YAML path.
6850 """
6851 return ".".join(part for part in path if part != "[]") or "<root>"
6852
6853
6854def _lookup_allowed_schema_keys(schema: dict, path: tuple):
6855 """!
6856 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
6857 @param[in] schema Role schema mapping.
6858 @param[in] path Internal path tuple.
6859 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
6860 """
6861 if path in schema:
6862 return schema[path]
6863 for idx, part in enumerate(path):
6864 if part == "[]":
6865 continue
6866 candidate = path[:idx] + ("*",) + path[idx + 1:]
6867 if candidate in schema:
6868 return schema[candidate]
6869 return False
6870
6871
6872def _schema_key_hint(schema: dict, path: tuple, key: str, allowed: set) -> str:
6873 """!
6874 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
6875 @param[in] schema Role schema mapping.
6876 @param[in] path Current internal YAML path tuple.
6877 @param[in] key Unsupported YAML key.
6878 @param[in] allowed Allowed keys at the current path.
6879 @return Optional hint string.
6880 """
6881 hints = []
6882 allowed_strings = sorted(str(item) for item in allowed)
6883 lower_matches = [item for item in allowed_strings if item.lower() == key.lower()]
6884 close_matches = lower_matches or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
6885 if close_matches:
6886 hints.append(f"Did you mean '{close_matches[0]}'?")
6887
6888 valid_paths = []
6889 for schema_path, schema_allowed in schema.items():
6890 if schema_path == path or not schema_allowed:
6891 continue
6892 if key in schema_allowed:
6893 valid_paths.append(_schema_path_text(schema_path))
6894 if valid_paths:
6895 hints.append(f"This key is valid at: {', '.join(sorted(valid_paths))}.")
6896
6897 return " ".join(hints)
6898
6899
6900def _validate_yaml_schema_keys(cfg, schema: dict, file_path: str, errors: list, path: tuple = ()) -> None:
6901 """!
6902 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
6903 @param[in] cfg Parsed YAML node.
6904 @param[in] schema Role schema mapping.
6905 @param[in] file_path Source file path for diagnostics.
6906 @param[in,out] errors Validation error accumulator.
6907 @param[in] path Current internal YAML path tuple.
6908 """
6909 if isinstance(cfg, dict):
6910 allowed = _lookup_allowed_schema_keys(schema, path)
6911 if allowed is not False and allowed is not None:
6912 unknown = sorted(str(key) for key in cfg.keys() if key not in allowed)
6913 for key in unknown:
6914 hint = _schema_key_hint(schema, path, key, allowed)
6915 hint_text = f" {hint}" if hint else ""
6916 errors.append(
6917 f" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
6918 f"Allowed keys: {sorted(allowed)}.{hint_text}"
6919 )
6920 if allowed is None:
6921 return
6922 for key, value in cfg.items():
6923 _validate_yaml_schema_keys(value, schema, file_path, errors, path + (key,))
6924 elif isinstance(cfg, list):
6925 for item in cfg:
6926 _validate_yaml_schema_keys(item, schema, file_path, errors, path + ("[]",))
6927
6928
6929_CASE_SCHEMA = {
6930 (): {
6931 "title", "properties", "run_control", "grid", "models", "boundary_conditions", "solver_parameters",
6932 },
6933 ("run_control",): {"start_step", "total_steps", "dt_physical"},
6934 ("properties",): {"scaling", "fluid", "initial_conditions"},
6935 ("properties", "scaling"): {"length_ref", "velocity_ref"},
6936 ("properties", "fluid"): {"density", "viscosity"},
6937 ("properties", "initial_conditions"): {
6938 "mode", "generator", "params", "field", "source_file",
6939 "u_physical", "v_physical", "w_physical", "peak_velocity_physical",
6940 "velocity_physical", "flow_direction",
6941 },
6942 ("properties", "initial_conditions", "params"): None,
6943 ("grid",): {
6944 "mode", "source_file", "programmatic_settings", "generator",
6945 "da_processors_x", "da_processors_y", "da_processors_z",
6946 },
6947 ("grid", "programmatic_settings"): {
6948 "im", "jm", "km", "xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs",
6949 "rxs", "rys", "rzs", "cgrids",
6950 "da_processors_x", "da_processors_y", "da_processors_z",
6951 },
6952 ("grid", "generator"): {
6953 "script", "config_file", "grid_type", "cli_args", "output_file", "stats_file", "vts_file",
6954 # Retained so existing warning behavior for typo-prone hyphen keys is not bypassed.
6955 "config-file", "grid-type", "output-file", "stats-file", "vts-file",
6956 },
6957 ("models",): {"domain", "physics"},
6958 ("models", "domain"): {"blocks"},
6959 ("models", "physics"): {"dimensionality", "fsi", "particles", "turbulence"},
6960 ("models", "physics", "fsi"): {"immersed", "moving_fsi"},
6961 ("models", "physics", "particles"): {"count", "init_mode", "restart_mode", "point_source"},
6962 ("models", "physics", "particles", "point_source"): {"x", "y", "z"},
6963 ("models", "physics", "turbulence"): {"les", "rans", "wall_function"},
6964 ("models", "physics", "turbulence", "les"): {
6965 "enabled", "model", "constant_cs", "dynamic_frequency", "filter_width",
6966 "test_filter", "averaging", "clipping", "gradient_model", "diagnostics",
6967 },
6968 ("models", "physics", "turbulence", "les", "test_filter"): {"kernel", "width_ratio"},
6969 ("models", "physics", "turbulence", "les", "averaging"): {"mode", "directions"},
6970 ("models", "physics", "turbulence", "les", "clipping"): {
6971 "mode", "max_cs", "min_viscosity_ratio",
6972 },
6973 ("models", "physics", "turbulence", "les", "gradient_model"): {"enabled"},
6974 ("models", "physics", "turbulence", "les", "diagnostics"): {
6975 "enabled", "cadence", "yoshizawa_ci",
6976 },
6977 ("models", "physics", "turbulence", "rans"): {"enabled", "model"},
6978 ("models", "physics", "turbulence", "wall_function"): {"enabled", "model", "roughness_height"},
6979 ("boundary_conditions", "[]"): {"face", "type", "handler", "params"},
6980 ("boundary_conditions", "[]", "[]"): {"face", "type", "handler", "params"},
6981 ("boundary_conditions", "[]", "params"): None,
6982 ("boundary_conditions", "[]", "[]", "params"): None,
6983 ("solver_parameters",): None,
6984}
6985
6986
6987_SOLVER_SCHEMA = {
6988 (): {
6989 "operation_mode", "strategy", "tolerances", "momentum_solver", "poisson_solver",
6990 "pressure_solver", "interpolation", "petsc_passthrough_options", "verification",
6991 "scalar_transport",
6992 },
6993 ("operation_mode",): {"eulerian_field_source", "analytical_type", "uniform_flow"},
6994 ("operation_mode", "uniform_flow"): {"u", "v", "w"},
6995 ("strategy",): {"momentum_solver", "central_diff"},
6996 ("tolerances",): {
6997 "max_iterations", "absolute_tol", "relative_tol", "step_tol",
6998 "residual_absolute_tol", "residual_relative_tol",
6999 },
7000 ("momentum_solver",): {
7001 "type", "dual_time_picard_jameson_rk", "dual_time_picard_rk4", "newton_krylov",
7002 },
7003 ("momentum_solver", "dual_time_picard_jameson_rk"): {
7004 "max_pseudo_steps", "absolute_tol", "relative_tol", "step_tol", "pseudo_cfl",
7005 "jameson_residual_noise_allowance_factor", "rk4_residual_noise_allowance_factor",
7006 "ratio_ema_alpha",
7007 },
7008 ("momentum_solver", "dual_time_picard_jameson_rk", "pseudo_cfl"): {
7009 "initial", "minimum", "maximum", "growth_factor", "reduction_factor",
7010 },
7011 ("momentum_solver", "dual_time_picard_rk4"): {
7012 "max_pseudo_steps", "absolute_tol", "relative_tol", "step_tol", "pseudo_cfl",
7013 "jameson_residual_noise_allowance_factor", "rk4_residual_noise_allowance_factor",
7014 "ratio_ema_alpha",
7015 },
7016 ("momentum_solver", "dual_time_picard_rk4", "pseudo_cfl"): {
7017 "initial", "minimum", "maximum", "growth_factor", "reduction_factor",
7018 },
7019 ("momentum_solver", "newton_krylov"): {
7020 "jacobian", "preconditioner", "nonlinear_solver", "linear_solver",
7021 },
7022 ("momentum_solver", "newton_krylov", "jacobian"): {"type", "finite_difference"},
7023 ("momentum_solver", "newton_krylov", "jacobian", "finite_difference"): {"mode"},
7024 ("momentum_solver", "newton_krylov", "preconditioner"): {"model", "structure"},
7025 ("momentum_solver", "newton_krylov", "preconditioner", "structure"): {"type"},
7026 ("momentum_solver", "newton_krylov", "nonlinear_solver"): {
7027 "method", "absolute_tolerance", "relative_tolerance", "step_tolerance",
7028 "max_iterations", "line_search", "eisenstat_walker",
7029 },
7030 ("momentum_solver", "newton_krylov", "nonlinear_solver", "line_search"): {"type"},
7031 ("momentum_solver", "newton_krylov", "nonlinear_solver", "eisenstat_walker"): {
7032 "enabled", "version", "initial_relative_tolerance", "maximum_relative_tolerance",
7033 "gamma", "exponent", "safeguard_exponent", "safeguard_threshold",
7034 },
7035 ("momentum_solver", "newton_krylov", "linear_solver"): {
7036 "method", "absolute_tolerance", "relative_tolerance", "max_iterations",
7037 "gmres", "preconditioner",
7038 },
7039 ("momentum_solver", "newton_krylov", "linear_solver", "gmres"): {"restart"},
7040 ("momentum_solver", "newton_krylov", "linear_solver", "preconditioner"): {"type"},
7041 ("poisson_solver",): {
7042 "method", "absolute_tolerance", "relative_tolerance", "max_iterations", "tolerance",
7043 "gmres", "preconditioner", "multigrid",
7044 },
7045 ("pressure_solver",): {
7046 "method", "absolute_tolerance", "relative_tolerance", "max_iterations", "tolerance",
7047 "gmres", "preconditioner", "multigrid",
7048 },
7049 ("poisson_solver", "gmres"): {"restart"},
7050 ("pressure_solver", "gmres"): {"restart"},
7051 ("poisson_solver", "preconditioner"): {"type"},
7052 ("pressure_solver", "preconditioner"): {"type"},
7053 ("poisson_solver", "multigrid"): {
7054 "levels", "pre_sweeps", "post_sweeps", "cycle", "mode", "semi_coarsening", "level_solvers",
7055 },
7056 ("pressure_solver", "multigrid"): {
7057 "levels", "pre_sweeps", "post_sweeps", "cycle", "mode", "semi_coarsening", "level_solvers",
7058 },
7059 ("poisson_solver", "multigrid", "semi_coarsening"): {"i", "j", "k"},
7060 ("pressure_solver", "multigrid", "semi_coarsening"): {"i", "j", "k"},
7061 ("poisson_solver", "multigrid", "level_solvers", "*"): {
7062 "method", "preconditioner", "ksp_type", "pc_type", "max_it", "rtol", "atol",
7063 },
7064 ("pressure_solver", "multigrid", "level_solvers", "*"): {
7065 "method", "preconditioner", "ksp_type", "pc_type", "max_it", "rtol", "atol",
7066 },
7067 ("interpolation",): {"method"},
7068 ("petsc_passthrough_options",): None,
7069 ("verification",): {"sources"},
7070 ("verification", "sources"): {"diffusivity", "scalar"},
7071 ("verification", "sources", "diffusivity"): {"mode", "profile", "gamma0", "slope_x"},
7072 ("verification", "sources", "scalar"): {
7073 "mode", "profile", "value", "phi0", "slope_x", "amplitude", "kx", "ky", "kz",
7074 },
7075 ("scalar_transport",): {"schmidt_number", "turbulent_schmidt_number"},
7076}
7077
7078
7079_MONITOR_SCHEMA = {
7080 (): {
7081 "logging", "profiling", "diagnostics", "io", "solver_monitoring", "solution_monitoring",
7082 "field_statistics",
7083 },
7084 ("logging",): {"verbosity", "enabled_functions"},
7085 ("profiling",): {"timestep_output", "final_summary"},
7086 ("profiling", "timestep_output"): {"mode", "functions", "file"},
7087 ("profiling", "final_summary"): {"enabled"},
7088 ("diagnostics",): {"petsc", "runtime_memory_log"},
7089 ("diagnostics", "petsc"): {
7090 "info", "malloc_debug", "malloc_test", "malloc_dump", "malloc_view", "malloc_view_threshold",
7091 "memory_view", "log_view", "log_view_memory", "log_all", "log_trace",
7092 "objects_dump", "options_left",
7093 },
7094 ("diagnostics", "petsc", "info"): {"enabled", "classes"},
7095 ("diagnostics", "runtime_memory_log"): {"enabled", "file"},
7096 ("io",): {
7097 "data_output_frequency", "particle_console_output_frequency", "particle_log_interval",
7098 "statistics_console_output_frequency",
7099 },
7100 ("solver_monitoring",): {"momentum", "poisson", "petsc_passthrough_options"},
7101 ("solver_monitoring", "momentum"): {
7102 "newton_krylov_history", "snes_monitor", "snes_converged_reason",
7103 "ksp_monitor", "ksp_converged_reason",
7104 },
7105 ("solver_monitoring", "poisson"): {"pic_true_residual", "true_residual", "converged_reason", "view"},
7106 ("solver_monitoring", "petsc_passthrough_options"): None,
7107 ("solution_monitoring",): {"convergence"},
7108 ("solution_monitoring", "convergence"): {
7109 "enabled", "mode", "periodic_deterministic", "statistical_steady",
7110 },
7111 ("solution_monitoring", "convergence", "periodic_deterministic"): {"period_steps"},
7112 ("solution_monitoring", "convergence", "statistical_steady"): {"window_steps"},
7113 ("field_statistics",): {"enabled", "windows"},
7114 ("field_statistics", "windows", "[]"): {
7115 "name", "start_time", "end_time", "weighting",
7116 "step_cadence", "time_cadence", "fields", "covariances",
7117 },
7118 ("field_statistics", "windows", "[]", "fields", "[]"): {"field", "moments"},
7119}
7120
7121
7122#: Eulerian fields a statistics window may accumulate, with the subsystem each needs.
7123#:
7124#: This is a curated subset of the typed catalog rather than a mirror of it. Averaging
7125#: a grid metric is meaningless, and a face-staggered field has no single pointwise
7126#: location, so offering the whole catalog would only move the rejection later and
7127#: make it harder to read. `tests/test_cli_smoke.py` asserts every entry here still
7128#: exists in `src/field_catalog.c` with the layout recorded, so the two cannot drift.
7129STATISTICS_ELIGIBLE_FIELDS = {
7130 "Ucat": {"components": 3, "requires": None},
7131 "P": {"components": 1, "requires": None},
7132 "Nvert": {"components": 1, "requires": None},
7133 "Phi": {"components": 1, "requires": None},
7134 "Psi": {"components": 1, "requires": "particles"},
7135 "ParticleCount": {"components": 1, "requires": "particles"},
7136 "Nu_t": {"components": 1, "requires": "turbulence"},
7137 "CS": {"components": 1, "requires": "les"},
7138}
7139
7140#: Moment names one field may request. Higher moments cannot be recovered from
7141#: centered state after the fact, so they need their own accumulation rather than
7142#: an extension of this list.
7143STATISTICS_MOMENT_NAMES = ("first", "second")
7144
7145#: Weighting modes a window may select.
7146STATISTICS_WEIGHTING_MODES = ("sample", "physical_time")
7147
7148
7149_POST_SCHEMA = {
7150 (): {
7151 "run_control", "source_data", "global_operations", "eulerian_pipeline",
7152 "lagrangian_pipeline", "statistics_pipeline", "statistics_output_prefix",
7153 "field_statistics", "spectra", "io",
7154 },
7155 ("field_statistics",): {"windows", "source_step", "outputs", "formats"},
7156 ("spectra",): {"output_prefix", "tasks"},
7157 ("spectra", "tasks", "[]"): {
7158 "task", "field", "block", "symbol", "subtract_mean", "mean_source_step",
7159 "reference",
7160 },
7161 ("run_control",): {
7162 "start_step", "end_step", "step_interval", "startTime", "endTime", "timeStep",
7163 },
7164 ("source_data",): {"directory", "input_extensions"},
7165 ("source_data", "input_extensions"): {"eulerian", "particle"},
7166 ("global_operations",): {"dimensionalize"},
7167 ("eulerian_pipeline", "[]"): {"task", "input_field", "output_field", "field", "reference_point"},
7168 ("lagrangian_pipeline", "[]"): {"task", "input_field", "output_field"},
7169 ("statistics_pipeline",): {"output_prefix", "tasks"},
7170 ("statistics_pipeline", "tasks", "[]"): {"task"},
7171 ("io",): {
7172 "output_directory", "output_filename_prefix", "particle_filename_prefix", "output_particles",
7173 "particle_subsampling_frequency", "input_extensions",
7174 "eulerian_fields", "particle_fields",
7175 },
7176 ("io", "input_extensions"): {"eulerian", "particle"},
7177}
7178
7179
7180_CLUSTER_SCHEMA = {
7181 (): {"scheduler", "resources", "notifications", "execution"},
7182 ("scheduler",): {"type"},
7183 ("resources",): {"account", "partition", "nodes", "ntasks_per_node", "mem", "time"},
7184 ("notifications",): {"mail_user", "mail_type"},
7185 ("execution",): {
7186 "module_setup", "launcher", "launcher_args", "extra_sbatch", "walltime_guard",
7187 },
7188 ("execution", "extra_sbatch"): None,
7189 ("execution", "walltime_guard"): {
7190 "enabled", "warmup_steps", "multiplier", "min_seconds", "estimator_alpha",
7191 },
7192}
7193
7194
7195_STUDY_SCHEMA = {
7196 (): {
7197 "title", "base_configs", "study_type", "parameters", "parameter_sets", "metrics", "plotting", "execution",
7198 },
7199 ("base_configs",): {"case", "solver", "monitor", "post"},
7200 ("parameters",): None,
7201 ("parameter_sets", "[]"): None,
7202 ("metrics", "[]"): {
7203 "name", "source", "file_glob", "column", "reduction", "normalize_by_parameter",
7204 "numerator_column", "denominator_column", "denominator_floor",
7205 "plot_label", "label", "units",
7206 },
7207 ("plotting",): {"enabled", "output_format"},
7208 ("execution",): {"max_concurrent_array_tasks"},
7209}
7210
7211
7212_WORKSPACE_SCHEMA = {
7213 (): {"schema_version", "workspace", "software", "paths", "reproducibility"},
7214 ("workspace",): {"id", "template", "created_at"},
7215 ("software",): {"picurv"},
7216 ("paths",): {"config", "inputs", "assets", "runs", "studies"},
7217 ("reproducibility",): {"require_clean_release", "pin_executables"},
7218}
7219
7220
7221# Directories the run owns and writes into. `log` is the safety-critical one: the C
7222# runtime calls PetscRMTree on it at the start of a fresh solve, so a value that
7223# escapes the run directory means the solver recursively deletes whatever is there.
7224RUN_OWNED_DIRECTORY_KEYS = ("log", "output")
7225UNSAFE_PATHS_OVERRIDE_KEY = "allow_unsafe_paths"
7226
7227# Directory names the run tree already owns. A run-owned directory must not collide
7228# with one of these: the log directory in particular is recursively deleted at the
7229# start of a fresh solve, so pointing it at `config` would destroy the run's own
7230# provenance before the solver had produced anything.
7231RESERVED_RUN_DIRECTORY_NAMES = ("config", "scheduler", "checkpoints", "visualization")
7232
7233# Defaults the runtime uses when a run-owned directory is not configured. An omitted
7234# key is not absent at runtime, so collision checks must resolve these first.
7235RUN_DIRECTORY_DEFAULTS = {"log": "logs", "output": "output"}
7236
7237# Runtime flags that select run-owned directories. The workspace contract owns them;
7238# raw passthrough must not replace the canonical values emitted by the generator.
7239RESERVED_DIRECTORY_FLAGS = (
7240 "-log_dir", "-output_dir", "-restart_dir", "-analysis_dir",
7241 "-allow_unsafe_log_dir",
7242)
7243
7244# PETSc indirection that could reintroduce a reserved flag without naming it. An
7245# options file or an alias is evaluated by PETSc itself, so its contents are outside
7246# every check this module performs.
7247RESERVED_INDIRECTION_FLAGS = ("-options_file", "-options_file_yaml", "-alias")
7248
7249
7250# Characters that cannot survive a PETSc options line unambiguously. A run
7251# subdirectory name containing whitespace, quotes, or a comment marker either needs
7252# quoting the generator does not apply, or changes how PETSc tokenizes the line.
7253# Rejecting them keeps the generated control file unambiguous by construction.
7254UNSAFE_DIRECTORY_CHARACTERS = ('"', "'", "#", "\n", "\r", "\t")
7255
7256
7257def directory_value_charset_problem(value: str) -> str:
7258 """!
7259 @brief Describe why a directory value cannot be written to a PETSc options line.
7260 @param[in] value Configured directory value.
7261 @return Human-readable reason, or an empty string when the value is safe.
7262 """
7263 if any(character.isspace() for character in value):
7264 return "contains whitespace"
7265 for character in UNSAFE_DIRECTORY_CHARACTERS:
7266 if character in value:
7267 label = {'"': "a double quote", "'": "a single quote", "#": "a comment marker"}.get(
7268 character, "a control character"
7269 )
7270 return f"contains {label}"
7271 return ""
7272
7273
7275 """!
7276 @brief Classify a configured run directory value.
7277
7278 @details Containment is judged lexically against the run directory, because the run
7279 directory does not exist yet at validation time. Beyond escaping, a value
7280 is rejected when it resolves to the run root itself: the log directory is
7281 recursively deleted on a fresh solve, so `.` or `a/..` would delete the run.
7282 @param[in] value Configured directory value from monitor `io.directories`.
7283 @return One of "contained", "escaping", "tilde", "run_root", or "invalid".
7284 """
7285 if not isinstance(value, str) or not value.strip():
7286 return "invalid"
7287 candidate = value.strip()
7288 # `~` is its own verdict because nothing expands it anywhere in the pipeline. The
7289 # control file is read by PETSc, not by a shell, and both the planner and the C
7290 # runtime treat a value not starting with `/` as relative to the run - so `~/logs`
7291 # names a literal `~` directory inside the run tree.
7292 #
7293 # It was worse before this verdict existed: physical containment classified `~` as
7294 # an authorizable external absolute location by expanding it, while every layer
7295 # that actually used the value treated it literally. The two disagreed about which
7296 # directory was being deleted. The fix is to refuse the value, not to expand it -
7297 # expanding it in one layer would only move the disagreement.
7298 if candidate.startswith("~"):
7299 return "tilde"
7300 if os.path.isabs(candidate):
7301 return "escaping"
7302 normalized = os.path.normpath(candidate)
7303 if normalized == os.pardir or normalized.startswith(os.pardir + os.sep):
7304 return "escaping"
7305 if normalized in (".", ""):
7306 return "run_root"
7307 return "contained"
7308
7309
7310def normalized_run_directory(value: str) -> str:
7311 """!
7312 @brief Normalized, comparable form of a contained run directory value.
7313 @param[in] value Configured directory value.
7314 @return Normalized relative path.
7315 """
7316 return os.path.normpath(str(value).strip()).replace(os.sep, "/")
7317
7318
7319def paths_overlap(first: str, second: str) -> bool:
7320 """!
7321 @brief Whether two run-relative directories are the same or nested in one another.
7322 @param[in] first Normalized directory.
7323 @param[in] second Normalized directory.
7324 @return True when one contains the other.
7325 """
7326 if first == second:
7327 return True
7328 return first.startswith(second + "/") or second.startswith(first + "/")
7329
7330
7331def resolve_unsafe_paths_override(dirs: dict, monitor_path: str) -> tuple:
7332 """!
7333 @brief Resolve the unsafe-paths override, requiring a real YAML boolean.
7334
7335 @details A truthy string such as "false" must never enable an override that permits
7336 a destructive path. Only a genuine boolean `true` enables it; any other
7337 type is a configuration error rather than a silent interpretation.
7338 @param[in] dirs The `io.directories` mapping.
7339 @param[in] monitor_path Path to the monitor file, for error messages.
7340 @return Tuple of (enabled, errors).
7341 """
7342 if UNSAFE_PATHS_OVERRIDE_KEY not in dirs:
7343 return False, []
7344 raw = dirs[UNSAFE_PATHS_OVERRIDE_KEY]
7345 if raw is True:
7346 return True, []
7347 if raw is False:
7348 return False, []
7349 return False, [
7350 f" {monitor_path}: 'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}' must be a YAML boolean "
7351 f"(true or false), got {raw!r}. Quoted strings and numbers are rejected so a value like "
7352 f"\"false\" cannot silently enable an unsafe path."
7353 ]
7354
7355
7356def evaluate_run_directories(values: dict, override: bool, explicit: set = None) -> tuple:
7357 """!
7358 @brief Apply every run-directory safety rule to a set of effective directory values.
7359
7360 @details Single source of truth for the rules, shared by configuration validation and
7361 submission preflight so the two cannot drift apart. Callers must pass
7362 *effective* values with defaults filled in.
7363
7364 Two classes of finding are distinguished. **Waivable** findings concern a
7365 deliberate external location; `allow_unsafe_paths` downgrades those to
7366 warnings. **Non-waivable** findings concern self-destruction or a value that
7367 cannot be written unambiguously - the run root itself, a reserved run
7368 directory, log/output overlap, and malformed characters. The override was
7369 granted for deliberate external storage, never for deleting the run's own
7370 config or emitting an ambiguous option line, so those stay errors.
7371 @param[in] values Effective mapping of directory key to configured value.
7372 @param[in] override Whether the unsafe-paths override is enabled.
7373 @param[in] explicit Keys the user actually configured; the rest are reported as defaults.
7374 @return Tuple of (errors, warnings) as bare messages without a file prefix.
7375 """
7376 errors: list = []
7377 warnings: list = []
7378 configured_keys = set(values) if explicit is None else set(explicit)
7379
7380 def waivable(message: str) -> None:
7381 """!
7382 @brief Record a finding the unsafe-paths override may downgrade.
7383 @param[in] message Finding text.
7384 @return None.
7385 """
7386 if override:
7387 warnings.append(
7388 f"{message} Allowed only because '{UNSAFE_PATHS_OVERRIDE_KEY}: true' is set."
7389 )
7390 else:
7391 errors.append(
7392 f"{message} Use a directory inside the run tree, or set "
7393 f"'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}: true' to override deliberately."
7394 )
7395
7396 def fatal(message: str) -> None:
7397 """!
7398 @brief Record a finding the override must never waive.
7399 @param[in] message Finding text.
7400 @return None.
7401 """
7402 errors.append(f"{message} This cannot be overridden.")
7403
7404 destructive_note = (
7405 "On a fresh solve the runtime RECURSIVELY DELETES this directory before writing to it."
7406 )
7407 resolved: dict = {}
7408 for key in RUN_OWNED_DIRECTORY_KEYS:
7409 if key not in values:
7410 continue
7411 raw = values[key]
7412 detail = destructive_note if key == "log" else (
7413 "Run output must stay inside the run directory so it can be archived and restored."
7414 )
7415 if not isinstance(raw, str) or not raw.strip():
7416 fatal(f"'io.directories.{key}' must be a non-empty relative path (got {raw!r}).")
7417 continue
7418
7419 # Both axes are reported. Escaping is the safety-critical property and leads,
7420 # but character problems are non-waivable, so a value that is both must not be
7421 # able to slip through by overriding only the escape.
7422 verdict = classify_run_directory_value(raw)
7423 charset_problem = directory_value_charset_problem(raw)
7424 if charset_problem:
7425 fatal(
7426 f"'io.directories.{key}' = {raw!r} {charset_problem}. Run directory names must be "
7427 f"writable to a PETSc options line without quoting; use a plain relative path such "
7428 f"as 'logs' or 'diagnostics/run1'."
7429 )
7430 if verdict == "tilde":
7431 fatal(
7432 f"'io.directories.{key}' = {raw!r} starts with '~', which nothing expands. "
7433 f"The control file is read by PETSc rather than by a shell, and the C "
7434 f"runtime resolves a value not starting with '/' relative to the run - so "
7435 f"this would be planned as one directory and deleted as another. Give a "
7436 f"real absolute path if an external location is intended. This cannot be "
7437 f"overridden."
7438 )
7439 continue
7440 if verdict == "escaping":
7441 if not (isinstance(raw, str) and raw.strip().startswith("/")):
7442 fatal(
7443 f"'io.directories.{key}' = {raw!r} escapes the run directory by relative "
7444 f"traversal. {detail} A relative escape lands among sibling runs and study "
7445 f"members, so it is never authorizable; give an absolute path if an external "
7446 f"location is genuinely intended."
7447 )
7448 continue
7449 # No `continue`: an authorized absolute path must still face the
7450 # non-waivable checks below, or it could target a run-owned directory such
7451 # as `/abs/run/config`.
7452 waivable(f"'io.directories.{key}' = {raw!r} escapes the run directory. {detail}")
7453 if verdict == "run_root":
7454 fatal(
7455 f"'io.directories.{key}' = {raw!r} resolves to the run directory itself. "
7456 + (destructive_note + " That would delete the entire run."
7457 if key == "log" else "Run output must live in its own subdirectory.")
7458 )
7459 continue
7460 if charset_problem:
7461 continue
7462 normalized = normalized_run_directory(raw)
7463 resolved[key] = normalized
7464 # Every segment is checked, not just the first: `./config`, `output/sub`, and
7465 # `/abs/run/config` all target run-owned directories.
7466 segments = [s for s in normalized.split("/") if s not in ("", ".")]
7467 hit = next((s for s in segments if s in RESERVED_RUN_DIRECTORY_NAMES), None)
7468 if hit:
7469 fatal(
7470 f"'io.directories.{key}' = {raw!r} targets the reserved run directory "
7471 f"'{hit}'. " + (destructive_note if key == "log"
7472 else "That directory is owned by the run tree.")
7473 )
7474
7475 if "log" in resolved and "output" in resolved and paths_overlap(resolved["log"], resolved["output"]):
7476 log_source = "" if "log" in configured_keys else " (default)"
7477 out_source = "" if "output" in configured_keys else " (default)"
7478 fatal(
7479 f"'io.directories.log' ({resolved['log']!r}{log_source}) and 'io.directories.output' "
7480 f"({resolved['output']!r}{out_source}) overlap. {destructive_note} "
7481 f"That would delete solver output."
7482 )
7483 return errors, warnings
7484
7485
7486# Typed physical verdicts. Waivability is decided on the verdict, never on a substring
7487# of the message: a finding reading "resolves to the run directory itself" contains no
7488# word like "escapes", so message matching silently downgraded self-destruction to a
7489# warning. Message text is for humans; these constants are for the rule.
7490PHYSICAL_VERDICT_CONTAINED = "contained"
7491PHYSICAL_VERDICT_RUN_ROOT = "run_root"
7492PHYSICAL_VERDICT_ANCESTOR = "ancestor"
7493PHYSICAL_VERDICT_RELATIVE_ESCAPE = "relative_escape"
7494PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE = "external_absolute"
7495
7496# The only verdict an explicit authorization may waive. An external absolute location
7497# is a deliberate, if dangerous, choice. Deleting the run itself, or an ancestor of it,
7498# or following a relative symlink out of the tree, are not choices anyone makes on
7499# purpose, so no authorization reaches them.
7500WAIVABLE_PHYSICAL_VERDICTS = frozenset({PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE})
7501
7502
7503def classify_physical_containment(run_dir: str, values: dict) -> list:
7504 """!
7505 @brief Classify where each run-owned directory physically lands.
7506
7507 @details Lexical containment is not enough: a contained name can be a symlink to an
7508 external directory, and `PetscRMTree` follows symlinks. This resolves the
7509 real path - including any symlinked ancestor - and reports a typed verdict
7510 so the caller can apply the waiver rule structurally.
7511 @param[in] run_dir Run directory the values are relative to.
7512 @param[in] values Effective directory mapping.
7513 @return List of (key, verdict, message) for every value that is not contained.
7514 """
7515 findings: list = []
7516 try:
7517 root = os.path.realpath(run_dir)
7518 except OSError:
7519 return findings
7520 for key in RUN_OWNED_DIRECTORY_KEYS:
7521 raw = values.get(key)
7522 if not isinstance(raw, str) or not raw.strip():
7523 continue
7524 text = raw.strip()
7525 # Whether the *value* was given as an absolute location, which is the only form
7526 # an authorization can cover. A relative name that resolves outside the tree got
7527 # there through a symlink, and that is never what an operator asked for.
7528 # Only a real absolute path can be waived. `~` is refused before this point by
7529 # the lexical rules, and is not expanded here either: an earlier version did
7530 # expand it, which made this layer reason about a home directory no other layer
7531 # ever resolves.
7532 absolute = text.startswith("/")
7533 candidate = os.path.join(run_dir, text)
7534 real = os.path.realpath(candidate)
7535
7536 if real == root:
7537 findings.append((key, PHYSICAL_VERDICT_RUN_ROOT,
7538 f"'io.directories.{key}' = {raw!r} resolves to the run directory itself "
7539 f"({real!r}); deleting it would destroy the run. This cannot be overridden."))
7540 elif real == os.sep or root.startswith(real.rstrip(os.sep) + os.sep):
7541 findings.append((key, PHYSICAL_VERDICT_ANCESTOR,
7542 f"'io.directories.{key}' = {raw!r} resolves to {real!r}, which CONTAINS the run "
7543 f"directory {root!r}. The runtime deletes this path recursively, so it would "
7544 f"destroy the run and everything beside it. This cannot be overridden."))
7545 elif real.startswith(root + os.sep):
7546 continue
7547 elif absolute:
7548 findings.append((key, PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE,
7549 f"'io.directories.{key}' = {raw!r} resolves to {real!r}, which is outside the run "
7550 f"directory {root!r}. The runtime deletes its log directory recursively."))
7551 else:
7552 findings.append((key, PHYSICAL_VERDICT_RELATIVE_ESCAPE,
7553 f"'io.directories.{key}' = {raw!r} is a relative name that resolves to {real!r}, "
7554 f"outside the run directory {root!r} - a symlink leads out of the tree. A "
7555 f"relative escape is never authorizable; name an absolute path if an external "
7556 f"location is genuinely intended. This cannot be overridden."))
7557 return findings
7558
7559
7560def check_physical_containment(run_dir: str, values: dict) -> list:
7561 """!
7562 @brief Human-readable physical containment violations.
7563 @param[in] run_dir Run directory the values are relative to.
7564 @param[in] values Effective directory mapping.
7565 @return Violation lines.
7566 """
7567 return [message for _, _, message in classify_physical_containment(run_dir, values)]
7568
7569
7570def effective_run_directories(configured: dict) -> dict:
7571 """!
7572 @brief Fill in defaults for run-owned directories that were not configured.
7573
7574 @details An omitted key is not absent at runtime, it takes its default. Checking
7575 only explicit keys would miss `log: output`, which collides with the
7576 default output directory and would delete solver output.
7577 @param[in] configured Configured directory mapping, possibly partial.
7578 @return Effective mapping with defaults applied.
7579 """
7580 effective = dict(RUN_DIRECTORY_DEFAULTS)
7581 for key in RUN_OWNED_DIRECTORY_KEYS:
7582 if key in configured:
7583 effective[key] = configured[key]
7584 return effective
7585
7586
7587def validate_run_directory_containment(monitor_cfg: dict, monitor_path: str) -> tuple:
7588 """!
7589 @brief Classify legacy directory values as defense-in-depth during validation.
7590 @details The monitor schema rejects this removed surface. Keeping the stricter
7591 classifier here ensures malformed or manually constructed configurations
7592 still receive the safety findings that protect recursive log cleanup.
7593 @param[in] monitor_cfg Parsed monitor YAML dictionary.
7594 @param[in] monitor_path Path to the monitor file, for error messages.
7595 @return Tuple of (errors, warnings).
7596 """
7597 io_cfg = (monitor_cfg or {}).get("io") or {}
7598 dirs = io_cfg.get("directories")
7599 if not isinstance(dirs, dict):
7600 return [], []
7601 override, override_errors = resolve_unsafe_paths_override(dirs, monitor_path)
7602 errors, warnings = evaluate_run_directories(
7603 effective_run_directories(dirs), override, explicit=set(dirs)
7604 )
7605 return (
7606 override_errors + [f" {monitor_path}: {message}" for message in errors],
7607 [f" {monitor_path}: {message}" for message in warnings],
7608 )
7609
7610
7611def validate_reserved_directory_flags(config: dict, config_path: str, label: str) -> list:
7612 """!
7613 @brief Reject raw PETSc passthrough options that set run-owned directories.
7614
7615 @details Passthrough surfaces emit `{flag: value}` verbatim into the generated
7616 control file. Run-owned path flags are reserved for the fixed workspace
7617 topology and may only be emitted by the generator.
7618 @param[in] config Parsed configuration mapping to scan.
7619 @param[in] config_path Path to the file, for error messages.
7620 @param[in] label Human-readable description of the surface being scanned.
7621 @return Violation lines.
7622 """
7623 violations: list = []
7624
7625 def scan(node, trail: str) -> None:
7626 """!
7627 @brief Walk the mapping looking for reserved flags used as keys.
7628 @param[in] node Current mapping, list, or scalar node.
7629 @param[in] trail Dotted path to the current node, for error messages.
7630 @return None.
7631 """
7632 if isinstance(node, dict):
7633 for key, value in node.items():
7634 token = key.strip() if isinstance(key, str) else key
7635 if token in RESERVED_DIRECTORY_FLAGS:
7636 violations.append(
7637 f" {config_path}: {label} sets the reserved flag '{token}' at "
7638 f"{trail or '<root>'}. Run directories are fixed by the workspace "
7639 "contract; raw passthrough cannot override them."
7640 )
7641 elif token in RESERVED_INDIRECTION_FLAGS:
7642 violations.append(
7643 f" {config_path}: {label} sets '{token}' at {trail or '<root>'}. PETSc "
7644 f"evaluates that indirection itself, so its contents cannot be checked "
7645 "here and could reintroduce a run-directory flag. Remove the indirection."
7646 )
7647 scan(value, f"{trail}.{key}" if trail else str(key))
7648 elif isinstance(node, list):
7649 for index, item in enumerate(node):
7650 scan(item, f"{trail}[{index}]")
7651
7652 scan(config, "")
7653 return violations
7654
7655
7656def validate_simulation_configs(case_cfg: dict, solver_cfg: dict, monitor_cfg: dict,
7657 case_path: str, solver_path: str, monitor_path: str):
7658 """!
7659 @brief Validates every configuration a simulation run consumes, before any work is done.
7660 @details Covers the three roles the solver is launched with: the case, the solver,
7661 and the monitor. Only one of those is a solver configuration, which is
7662 why this is not named for the solver; the monitor in particular carries
7663 observation and field-statistics contracts that have nothing to do with
7664 the numerical scheme.
7665
7666 Checks required sections, required keys, physical sanity, and the
7667 cross-file combinations no single file can rule out. Post-processing
7668 configuration is validated separately by `validate_post_config()`.
7669 @param[in] case_cfg Parsed case YAML dictionary.
7670 @param[in] solver_cfg Parsed solver YAML dictionary.
7671 @param[in] monitor_cfg Parsed monitor YAML dictionary.
7672 @param[in] case_path Path to case file (for error messages).
7673 @param[in] solver_path Path to solver file (for error messages).
7674 @param[in] monitor_path Path to monitor file (for error messages).
7675 @throws SystemExit on validation failure.
7676 """
7677 errors = []
7678 warnings = []
7680 case_cfg, case_path, "case solver_parameters / passthrough"))
7682 solver_cfg, solver_path, "solver petsc_passthrough_options"))
7683 eulerian_source_mode = "solve"
7684
7685 legacy_statistics = (case_cfg.get("models", {}) or {}).get("statistics") if isinstance(case_cfg, dict) else None
7686 if legacy_statistics is not None:
7687 errors.append(
7688 f" {case_path}: 'models.statistics' was removed with the legacy averaging system. "
7689 "Use instantaneous output and offline postprocessing until the replacement "
7690 "field-statistics pipeline is available."
7691 )
7692 if isinstance(solver_cfg, dict) and "solution_convergence" in solver_cfg:
7693 errors.append(
7694 f" {solver_path}: 'solution_convergence' moved to "
7695 "monitor.yml -> solution_monitoring.convergence."
7696 )
7697 if errors:
7699
7700 _validate_yaml_schema_keys(case_cfg, _CASE_SCHEMA, case_path, errors)
7701 _validate_yaml_schema_keys(solver_cfg, _SOLVER_SCHEMA, solver_path, errors)
7702 _validate_yaml_schema_keys(monitor_cfg, _MONITOR_SCHEMA, monitor_path, errors)
7703
7704 # --- case.yml: required top-level sections ---
7705 required_case_sections = ['properties', 'run_control', 'grid', 'models', 'boundary_conditions']
7706 for section in required_case_sections:
7707 if section not in case_cfg:
7708 errors.append(f" {case_path}: missing required section '{section}'.")
7709
7710 if errors:
7712
7713 # --- case.yml: properties sub-keys ---
7714 props = case_cfg.get('properties', {})
7715 for group, keys in [('scaling', ['length_ref', 'velocity_ref']),
7716 ('fluid', ['density', 'viscosity'])]:
7717 sub = props.get(group, {})
7718 if not sub:
7719 errors.append(f" {case_path}: missing 'properties.{group}' section.")
7720 else:
7721 for k in keys:
7722 if k not in sub:
7723 errors.append(f" {case_path}: missing key 'properties.{group}.{k}'.")
7724
7725 # --- case.yml: run_control sub-keys ---
7726 rc = case_cfg.get('run_control', {})
7727 for k in ['start_step', 'total_steps', 'dt_physical']:
7728 if k not in rc:
7729 errors.append(f" {case_path}: missing key 'run_control.{k}'.")
7730
7731 # --- Physical sanity checks ---
7732 try:
7733 density = float(props.get('fluid', {}).get('density', 0))
7734 viscosity = float(props.get('fluid', {}).get('viscosity', 0))
7735 dt = float(rc.get('dt_physical', 0))
7736 if density <= 0:
7737 errors.append(f" {case_path}: 'properties.fluid.density' must be positive (got {density}).")
7738 if viscosity < 0:
7739 errors.append(f" {case_path}: 'properties.fluid.viscosity' must be non-negative (got {viscosity}).")
7740 if dt <= 0:
7741 errors.append(f" {case_path}: 'run_control.dt_physical' must be positive (got {dt}).")
7742 except (TypeError, ValueError):
7743 pass # Will be caught later during processing
7744
7745 # --- case.yml: grid mode ---
7746 grid_cfg = case_cfg.get('grid', {})
7747 grid_mode = grid_cfg.get('mode')
7748 valid_grid_modes = list(GRID_MODES)
7749 if grid_mode not in valid_grid_modes:
7750 errors.append(f" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
7751 elif grid_mode == 'file':
7752 source_file = grid_cfg.get('source_file')
7753 if not source_file:
7754 errors.append(f" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
7755 else:
7756 try:
7757 source_abs = resolve_workspace_path(case_path, source_file)
7758 except ValueError as exc:
7759 errors.append(f" {case_path}: {exc}")
7760 else:
7761 if not os.path.isfile(source_abs):
7762 errors.append(f" {case_path}: grid.source_file does not exist: {source_abs}")
7763 elif grid_mode == 'programmatic_c':
7764 grid_settings = grid_cfg.get('programmatic_settings')
7765 if not grid_settings:
7766 errors.append(f" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
7767 elif not isinstance(grid_settings, dict):
7768 errors.append(f" {case_path}: 'grid.programmatic_settings' must be a mapping.")
7769 elif grid_mode == 'grid_gen':
7770 gen_cfg = grid_cfg.get('generator')
7771 if not isinstance(gen_cfg, dict):
7772 errors.append(f" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
7773 else:
7774 warn_on_grid_generator_hyphen_keys(gen_cfg, case_path, warnings)
7775 errors.extend(
7776 reject_generator_destination_keys(gen_cfg, case_path, "grid.generator")
7777 )
7778
7779 config_file = gen_cfg.get('config_file')
7780 if not config_file:
7781 errors.append(f" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
7782 else:
7783 try:
7784 config_abs = resolve_workspace_path(case_path, config_file)
7785 except ValueError as exc:
7786 errors.append(f" {case_path}: {exc}")
7787 else:
7788 if not os.path.isfile(config_abs):
7789 errors.append(f" {case_path}: grid.generator.config_file does not exist: {config_abs}")
7790
7791 grid_type = gen_cfg.get('grid_type')
7792 if grid_type is not None and str(grid_type) not in GRID_GENERATOR_TYPES:
7793 errors.append(f" {case_path}: grid.generator.grid_type must be one of "
7794 f"{list(GRID_GENERATOR_TYPES)} (got '{grid_type}').")
7795
7796 # cli_args is an opaque token list handed straight to the generator, so a bad
7797 # geometry selector inside it would otherwise surface as a nonzero subprocess
7798 # exit in the middle of a run rather than as a validation error before one.
7799 errors.extend(validate_grid_generator_cli_args(gen_cfg.get('cli_args'), case_path))
7800
7801 cli_args = gen_cfg.get('cli_args', [])
7802 if cli_args is not None and not isinstance(cli_args, list):
7803 errors.append(f" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
7804 try:
7806 except ValueError as e:
7807 errors.append(f" {case_path}: {e}")
7808
7809 # --- case.yml: boundary_conditions strict validation ---
7810 prepared_blocks = None
7811 try:
7812 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
7813 except ValueError as e:
7814 errors.append(f" {case_path}: {e}")
7815
7816 # --- case.yml: initial_conditions mode-aware validation ---
7817 ic = props.get('initial_conditions', {})
7818 resolved_ic = None
7819 try:
7820 ic_start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
7821 except (TypeError, ValueError):
7822 ic_start_step = 0
7823 try:
7824 ic_eulerian_source = normalize_eulerian_field_source(
7825 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
7826 )
7827 except ValueError:
7828 # The solver-specific validation below reports an invalid source value.
7829 # Keep IC validation independent of that diagnostic.
7830 ic_eulerian_source = "solve"
7831 ic_is_authoritative = ic_eulerian_source == "solve" and ic_start_step == 0
7832 if not ic:
7833 errors.append(f" {case_path}: missing 'properties.initial_conditions' section.")
7834 elif not isinstance(ic, dict):
7835 errors.append(f" {case_path}: 'properties.initial_conditions' must be a mapping.")
7836 elif 'mode' not in ic:
7837 errors.append(
7838 f" {case_path}: missing key 'properties.initial_conditions.mode'. "
7839 "Specify 'generated' or 'file' explicitly."
7840 )
7841 elif ic_is_authoritative:
7842 try:
7843 scaling_contract = resolve_fluid_scaling(case_cfg)
7845 ic, prepared_blocks, U_ref=scaling_contract["velocity_ref"],
7846 provider_context={"kinematic_viscosity": scaling_contract["nondimensional_kinematic_viscosity"]},
7847 )
7848 except KeyError as e:
7849 errors.append(f" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
7850 except ValueError as e:
7851 errors.append(f" {case_path}: {e}")
7852 if (ic_is_authoritative and resolved_ic and
7853 GENERATED_IC_PROVIDERS.get(resolved_ic.get("kind"), {}).get("requires_fresh_3d")):
7854 dimensionality = str((((case_cfg.get("models", {}) or {}).get("physics", {}) or {})
7855 .get("dimensionality", "3D"))).strip().upper()
7856 if dimensionality != "3D":
7857 errors.append(f" {case_path}: {resolved_ic['label']} requires models.physics.dimensionality: 3D.")
7858 if grid_mode == "programmatic_c":
7859 settings = grid_cfg.get("programmatic_settings", {}) or {}
7860 ratios = [settings.get(key, 1.0) for key in ("rxs", "rys", "rzs")]
7861 try:
7862 if any(abs(float(value) - 1.0) > 1.0e-12 for value in ratios):
7863 errors.append(f" {case_path}: {resolved_ic['label']} requires uniform programmatic spacing (rxs/rys/rzs: 1.0).")
7864 except (TypeError, ValueError):
7865 pass
7866 if grid_mode == 'programmatic_c' and resolved_ic and is_generated_ic_provider(resolved_ic):
7867 try:
7868 validate_programmatic_generated_ic_grid_settings(grid_cfg.get('programmatic_settings'))
7869 except ValueError as e:
7870 errors.append(f" {case_path}: {e}")
7871
7872 # --- case.yml: particle initialization validation ---
7873 particles_cfg = case_cfg.get('models', {}).get('physics', {}).get('particles', {})
7874 if particles_cfg and not isinstance(particles_cfg, dict):
7875 errors.append(f" {case_path}: 'models.physics.particles' must be a mapping.")
7876 elif isinstance(particles_cfg, dict):
7877 init_mode_raw = particles_cfg.get('init_mode', 'Surface')
7878 try:
7879 pinit_code = normalize_particle_init_mode(init_mode_raw)
7880 except ValueError as e:
7881 errors.append(f" {case_path}: {e}")
7882 pinit_code = None
7883
7884 restart_mode = particles_cfg.get('restart_mode')
7885 if restart_mode is not None and str(restart_mode).lower() not in PARTICLE_RESTART_MODES:
7886 errors.append(
7887 f" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
7888 )
7889 elif 'restart_mode' not in particles_cfg:
7890 try:
7891 start_step = int(rc.get('start_step', 0))
7892 particle_count = int(particles_cfg.get('count', 0) or 0)
7893 except (TypeError, ValueError):
7894 start_step = 0
7895 particle_count = 0
7896 if start_step > 0 and particle_count > 0:
7897 warnings.append(
7898 f"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
7899 "(run_control.start_step > 0, count > 0). C will default to 'load'."
7900 )
7901
7902 if pinit_code == 2:
7903 point_cfg = particles_cfg.get('point_source', {})
7904 if not isinstance(point_cfg, dict):
7905 errors.append(f" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
7906 else:
7907 for coord in ('x', 'y', 'z'):
7908 if coord not in point_cfg:
7909 errors.append(
7910 f" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
7911 )
7912
7913 # --- case.yml: turbulence model validation ---
7914 turbulence_cfg = case_cfg.get('models', {}).get('physics', {}).get('turbulence', {})
7915 if turbulence_cfg is not None and not isinstance(turbulence_cfg, dict):
7916 errors.append(f" {case_path}: 'models.physics.turbulence' must be a mapping.")
7917 elif isinstance(turbulence_cfg, dict) and turbulence_cfg:
7918 try:
7919 append_turbulence_flags(case_cfg.get('models', {}), [])
7920 except ValueError as e:
7921 errors.append(f" {case_path}: {e}")
7922
7923 les_cfg = turbulence_cfg.get('les')
7924 rans_cfg = turbulence_cfg.get('rans')
7925 wall_cfg = turbulence_cfg.get('wall_function')
7926
7927 if isinstance(les_cfg, dict):
7928 validate_les_configuration(case_cfg, les_cfg, case_path, errors, warnings)
7929
7930 validate_wall_model_pairing(case_cfg, les_cfg, rans_cfg, wall_cfg, case_path,
7931 errors, warnings)
7932
7933 if isinstance(rans_cfg, dict):
7934 if 'enabled' in rans_cfg and not isinstance(rans_cfg['enabled'], bool):
7935 errors.append(f" {case_path}: models.physics.turbulence.rans.enabled must be true or false.")
7936 try:
7937 rans_enabled = bool(rans_cfg.get('enabled', True)) and normalize_rans_model(rans_cfg.get('model', 'k_omega')) != 0
7938 except ValueError:
7939 rans_enabled = False
7940 if rans_enabled:
7941 warnings.append(
7942 f"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
7943 )
7944 elif rans_cfg:
7945 warnings.append(
7946 f"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
7947 )
7948
7949 if isinstance(wall_cfg, dict):
7950 if 'enabled' in wall_cfg and not isinstance(wall_cfg['enabled'], bool):
7951 errors.append(f" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
7952 if 'roughness_height' in wall_cfg:
7953 try:
7954 value = float(wall_cfg['roughness_height'])
7955 if value < 0.0:
7956 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
7957 except (TypeError, ValueError):
7958 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
7959 # Only the log law has a roughness formulation. Werner-Wengle has no
7960 # roughness term and Cabot discards the argument, so accepting the key
7961 # for either would silently ignore a value the user set deliberately.
7962 try:
7963 wall_model = normalize_wall_function_model(wall_cfg.get('model'))
7964 except ValueError:
7965 wall_model = None
7966 if wall_model in (2, 3):
7967 errors.append(
7968 f" {case_path}: models.physics.turbulence.wall_function.roughness_height "
7969 "applies only to model 'log_law'; 'werner' has no roughness formulation and "
7970 "'cabot' ignores it. Remove the key or select 'log_law'.")
7971
7972 # --- solver.yml: basic structure ---
7973 if not isinstance(solver_cfg, dict) or not solver_cfg:
7974 errors.append(f" {solver_path}: solver config is empty or not a valid YAML mapping.")
7975 else:
7976 strategy_cfg = solver_cfg.get('strategy', {})
7977 if not isinstance(strategy_cfg, dict):
7978 errors.append(f" {solver_path}: 'strategy' must be a mapping.")
7979 elif 'implicit' in strategy_cfg:
7980 errors.append(
7981 f" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
7982 "Use 'strategy.momentum_solver' with named solver values."
7983 )
7984 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
7985 try:
7986 normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
7987 except ValueError as e:
7988 errors.append(f" {solver_path}: {e}")
7989
7990 op_mode_cfg = solver_cfg.get('operation_mode', {})
7991 if op_mode_cfg is not None and not isinstance(op_mode_cfg, dict):
7992 errors.append(f" {solver_path}: 'operation_mode' must be a mapping when provided.")
7993 elif isinstance(op_mode_cfg, dict):
7994 eulerian_source_mode = None
7995 normalized_analytical_type = None
7996 if 'eulerian_field_source' in op_mode_cfg:
7997 try:
7998 eulerian_source_mode = normalize_eulerian_field_source(op_mode_cfg.get('eulerian_field_source'))
7999 except ValueError as e:
8000 errors.append(f" {solver_path}: {e}")
8001
8002 analytical_type = op_mode_cfg.get('analytical_type')
8003 if analytical_type is not None:
8004 try:
8005 normalized_analytical_type = normalize_analytical_type(analytical_type)
8006 except ValueError as e:
8007 errors.append(f" {solver_path}: {e}")
8008 else:
8009 uniform_flow_cfg = op_mode_cfg.get('uniform_flow')
8010 if uniform_flow_cfg is not None and not isinstance(uniform_flow_cfg, dict):
8011 errors.append(f" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
8012 elif normalized_analytical_type == "UNIFORM_FLOW":
8013 if not isinstance(uniform_flow_cfg, dict):
8014 errors.append(
8015 f" {solver_path}: operation_mode.uniform_flow is required when "
8016 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
8017 )
8018 else:
8019 for coord in ("u", "v", "w"):
8020 if coord not in uniform_flow_cfg:
8021 errors.append(
8022 f" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
8023 )
8024 else:
8025 try:
8026 float(uniform_flow_cfg[coord])
8027 except (TypeError, ValueError):
8028 errors.append(
8029 f" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
8030 )
8031 elif uniform_flow_cfg is not None:
8032 errors.append(
8033 f" {solver_path}: operation_mode.uniform_flow is only valid when "
8034 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
8035 )
8036
8037 if eulerian_source_mode == "analytical":
8038 effective_analytical_type = normalized_analytical_type or "TGV3D"
8039 if effective_analytical_type == "TGV3D":
8040 if grid_mode != 'programmatic_c':
8041 errors.append(
8042 f" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
8043 "'programmatic_c'. File-backed analytical ingestion is only supported for "
8044 "ZERO_FLOW and UNIFORM_FLOW."
8045 )
8046 elif isinstance(grid_cfg.get('programmatic_settings'), dict):
8047 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
8048 if missing_dims:
8049 errors.append(
8050 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
8051 f"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
8052 )
8053 else:
8054 if grid_mode not in (GRID_MODES[1], GRID_MODES[0]):
8055 errors.append(
8056 f" {case_path}: grid.mode '{grid_mode}' is not supported when "
8057 f"operation_mode.analytical_type is '{effective_analytical_type}'. "
8058 "Use 'programmatic_c' or 'file'."
8059 )
8060 elif grid_mode == 'programmatic_c' and isinstance(grid_cfg.get('programmatic_settings'), dict):
8061 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
8062 if missing_dims:
8063 errors.append(
8064 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
8065 f"operation_mode.analytical_type is '{effective_analytical_type}' and "
8066 "grid.mode is 'programmatic_c'."
8067 )
8068
8069 verification_cfg = solver_cfg.get('verification', {})
8070 if verification_cfg is not None and not isinstance(verification_cfg, dict):
8071 errors.append(f" {solver_path}: 'verification' must be a mapping when provided.")
8072 elif isinstance(verification_cfg, dict) and verification_cfg:
8073 sources_cfg = verification_cfg.get('sources', {})
8074 if sources_cfg is not None and not isinstance(sources_cfg, dict):
8075 errors.append(f" {solver_path}: 'verification.sources' must be a mapping when provided.")
8076 elif isinstance(sources_cfg, dict) and sources_cfg:
8077 diff_cfg = sources_cfg.get('diffusivity')
8078 scalar_cfg = sources_cfg.get('scalar')
8079
8080 if diff_cfg is not None:
8081 if not isinstance(diff_cfg, dict):
8082 errors.append(f" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
8083 else:
8084 if eulerian_source_mode != "analytical":
8085 errors.append(
8086 f" {solver_path}: verification.sources.diffusivity is only valid when "
8087 "operation_mode.eulerian_field_source is 'analytical'."
8088 )
8089 mode = diff_cfg.get('mode')
8090 profile = diff_cfg.get('profile')
8091 if str(mode).strip().lower() != "analytical":
8092 errors.append(
8093 f" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
8094 )
8095 if str(profile).strip().upper() != "LINEAR_X":
8096 errors.append(
8097 f" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
8098 )
8099 for key in ("gamma0", "slope_x"):
8100 if key not in diff_cfg:
8101 errors.append(
8102 f" {solver_path}: verification.sources.diffusivity.{key} is required."
8103 )
8104 else:
8105 try:
8106 float(diff_cfg[key])
8107 except (TypeError, ValueError):
8108 errors.append(
8109 f" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
8110 )
8111
8112 if scalar_cfg is not None:
8113 if not isinstance(scalar_cfg, dict):
8114 errors.append(f" {solver_path}: 'verification.sources.scalar' must be a mapping.")
8115 else:
8116 if eulerian_source_mode != "analytical":
8117 errors.append(
8118 f" {solver_path}: verification.sources.scalar is only valid when "
8119 "operation_mode.eulerian_field_source is 'analytical'."
8120 )
8121 mode = scalar_cfg.get('mode')
8122 profile = str(scalar_cfg.get('profile', '')).strip().upper()
8123 if str(mode).strip().lower() != "analytical":
8124 errors.append(
8125 f" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
8126 )
8127 if profile not in VERIFICATION_SCALAR_PROFILES:
8128 errors.append(
8129 f" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
8130 )
8131 required_scalar_keys = {
8132 "CONSTANT": ("value",),
8133 "LINEAR_X": ("phi0", "slope_x"),
8134 "SIN_PRODUCT": ("amplitude", "kx", "ky", "kz"),
8135 }.get(profile, ())
8136 for key in required_scalar_keys:
8137 if key not in scalar_cfg:
8138 errors.append(
8139 f" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
8140 )
8141 else:
8142 try:
8143 float(scalar_cfg[key])
8144 except (TypeError, ValueError):
8145 errors.append(
8146 f" {solver_path}: verification.sources.scalar.{key} must be numeric."
8147 )
8148
8149 unknown_source_keys = sorted(set(sources_cfg.keys()) - {"diffusivity", "scalar"})
8150 if unknown_source_keys:
8151 errors.append(
8152 f" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
8153 "Currently supported: 'diffusivity', 'scalar'."
8154 )
8155 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {"sources"})
8156 if unknown_verification_keys:
8157 errors.append(
8158 f" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
8159 "Currently supported: 'sources'."
8160 )
8161
8162 transport_cfg = solver_cfg.get('scalar_transport', {})
8163 if transport_cfg is not None and not isinstance(transport_cfg, dict):
8164 errors.append(f" {solver_path}: 'scalar_transport' must be a mapping when provided.")
8165 elif isinstance(transport_cfg, dict):
8166 unknown_transport_keys = sorted(set(transport_cfg.keys()) - {"schmidt_number", "turbulent_schmidt_number"})
8167 if unknown_transport_keys:
8168 errors.append(
8169 f" {solver_path}: unsupported scalar_transport entries: {unknown_transport_keys}. "
8170 "Currently supported: 'schmidt_number', 'turbulent_schmidt_number'."
8171 )
8172 for key in ("schmidt_number", "turbulent_schmidt_number"):
8173 if key in transport_cfg:
8174 try:
8175 value = float(transport_cfg[key])
8176 if value <= 0.0:
8177 errors.append(f" {solver_path}: scalar_transport.{key} must be positive.")
8178 except (TypeError, ValueError):
8179 errors.append(f" {solver_path}: scalar_transport.{key} must be numeric.")
8180
8181 tolerances_cfg = solver_cfg.get('tolerances', {})
8182 if tolerances_cfg is not None and not isinstance(tolerances_cfg, dict):
8183 errors.append(f" {solver_path}: 'tolerances' must be a mapping when provided.")
8184 elif isinstance(tolerances_cfg, dict):
8185 for key in ("absolute_tol", "relative_tol", "residual_absolute_tol", "residual_relative_tol"):
8186 if key in tolerances_cfg:
8187 try:
8188 float(tolerances_cfg[key])
8189 except (TypeError, ValueError):
8190 errors.append(f" {solver_path}: tolerances.{key} must be numeric.")
8191
8192 ms_cfg = solver_cfg.get('momentum_solver', {})
8193 if ms_cfg is not None and not isinstance(ms_cfg, dict):
8194 errors.append(f" {solver_path}: 'momentum_solver' must be a mapping when provided.")
8195 elif isinstance(ms_cfg, dict):
8196 unsupported_flat_keys = {
8197 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
8198 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
8199 'rk4_residual_noise_allowance_factor'
8200 }
8201 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
8202 if present_unsupported:
8203 errors.append(
8204 f" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
8205 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
8206 )
8207
8208 allowed_ms_keys = {'dual_time_picard_jameson_rk', 'dual_time_picard_rk4', 'newton_krylov'}
8209 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
8210 if unknown_ms_keys:
8211 errors.append(
8212 f" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
8213 "Currently supported: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
8214 )
8215 if 'dual_time_picard_jameson_rk' in ms_cfg and 'dual_time_picard_rk4' in ms_cfg:
8216 errors.append(
8217 f" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
8218 "do not also set its deprecated dual_time_picard_rk4 alias."
8219 )
8220
8221 selected_solver = None
8222 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
8223 try:
8224 selected_solver = normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
8225 except ValueError:
8226 pass
8227 if selected_solver is None:
8228 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
8229
8230 has_dualtime_block = (
8231 'dual_time_picard_jameson_rk' in ms_cfg or 'dual_time_picard_rk4' in ms_cfg
8232 )
8233 if selected_solver != "DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
8234 errors.append(
8235 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
8236 f"{selected_solver}. Solver-specific blocks must match the selected solver."
8237 )
8238
8239 newton_cfg = ms_cfg.get('newton_krylov')
8240 if newton_cfg is not None:
8241 if selected_solver != "newton_krylov":
8242 errors.append(
8243 f" {solver_path}: momentum_solver.newton_krylov is set but selected solver is "
8244 f"{selected_solver}. Solver-specific blocks must match the selected solver."
8245 )
8246 try:
8248 except ValueError as exc:
8249 errors.append(f" {solver_path}: {exc}")
8250
8251 dt_picard_cfg = ms_cfg.get('dual_time_picard_jameson_rk', ms_cfg.get('dual_time_picard_rk4'))
8252 if dt_picard_cfg is not None:
8253 if not isinstance(dt_picard_cfg, dict):
8254 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
8255 else:
8256 allowed_dt_keys = {
8257 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
8258 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
8259 'rk4_residual_noise_allowance_factor', 'ratio_ema_alpha'
8260 }
8261 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
8262 if unknown_dt_keys:
8263 errors.append(
8264 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
8265 )
8266 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
8267 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
8268 errors.append(
8269 f" {solver_path}: use only jameson_residual_noise_allowance_factor; "
8270 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
8271 )
8272 if 'pseudo_cfl' in dt_picard_cfg:
8273 pcfl_cfg = dt_picard_cfg['pseudo_cfl']
8274 if not isinstance(pcfl_cfg, dict):
8275 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
8276 else:
8277 allowed_pcfl_keys = {'initial', 'minimum', 'maximum', 'growth_factor', 'reduction_factor'}
8278 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
8279 if unknown_pcfl_keys:
8280 errors.append(
8281 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
8282 )
8283 numeric_pcfl = {}
8284 for key in allowed_pcfl_keys:
8285 if key in pcfl_cfg:
8286 try:
8287 numeric_pcfl[key] = float(pcfl_cfg[key])
8288 except (TypeError, ValueError):
8289 errors.append(
8290 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
8291 )
8292 if numeric_pcfl.get('minimum', 1.0) <= 0.0:
8293 errors.append(f" {solver_path}: pseudo_cfl.minimum must be positive.")
8294 if numeric_pcfl.get('growth_factor', 1.0) < 1.0:
8295 errors.append(f" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
8296 reduction = numeric_pcfl.get('reduction_factor', 1.0)
8297 if reduction <= 0.0 or reduction >= 1.0:
8298 errors.append(f" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
8299 if all(key in numeric_pcfl for key in ('minimum', 'initial', 'maximum')):
8300 if not numeric_pcfl['minimum'] <= numeric_pcfl['initial'] <= numeric_pcfl['maximum']:
8301 errors.append(f" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
8302 noise_key = (
8303 'jameson_residual_noise_allowance_factor'
8304 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
8305 else 'rk4_residual_noise_allowance_factor'
8306 )
8307 if noise_key in dt_picard_cfg:
8308 try:
8309 if float(dt_picard_cfg[noise_key]) < 1.0:
8310 errors.append(f" {solver_path}: {noise_key} must be at least 1.")
8311 except (TypeError, ValueError):
8312 errors.append(f" {solver_path}: {noise_key} must be numeric.")
8313 if 'ratio_ema_alpha' in dt_picard_cfg:
8314 try:
8315 alpha_val = float(dt_picard_cfg['ratio_ema_alpha'])
8316 if not 0.0 <= alpha_val <= 1.0:
8317 errors.append(f" {solver_path}: ratio_ema_alpha must be in [0, 1].")
8318 except (TypeError, ValueError):
8319 errors.append(f" {solver_path}: ratio_ema_alpha must be numeric.")
8320
8321 # --- solver.yml: interpolation section ---
8322 interp_cfg = solver_cfg.get('interpolation', {}) if isinstance(solver_cfg, dict) else {}
8323 if interp_cfg is not None and not isinstance(interp_cfg, dict):
8324 errors.append(f" {solver_path}: 'interpolation' must be a mapping when provided.")
8325 elif isinstance(interp_cfg, dict) and 'method' in interp_cfg:
8326 try:
8327 normalize_interpolation_method(interp_cfg['method'])
8328 except ValueError as e:
8329 errors.append(f" {solver_path}: {e}")
8330
8331 # --- monitor.yml: basic structure ---
8332 if not isinstance(monitor_cfg, dict) or not monitor_cfg:
8333 errors.append(f" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
8334 else:
8335 io_cfg = monitor_cfg.get('io', {})
8336 freq = io_cfg.get('data_output_frequency')
8337 if freq is not None and (not isinstance(freq, int) or freq <= 0):
8338 errors.append(f" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
8339 particle_console_freq = io_cfg.get('particle_console_output_frequency')
8340 if particle_console_freq is not None and (not isinstance(particle_console_freq, int) or particle_console_freq < 0):
8341 errors.append(
8342 f" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
8343 f"(got {particle_console_freq})."
8344 )
8345 containment_errors, containment_warnings = validate_run_directory_containment(
8346 monitor_cfg, monitor_path
8347 )
8348 errors.extend(containment_errors)
8349 warnings.extend(containment_warnings)
8351 monitor_cfg, monitor_path, "monitor passthrough"))
8352 try:
8353 resolve_profiling_config(monitor_cfg)
8354 except ValueError as e:
8355 errors.append(f" {monitor_path}: {e}")
8356 try:
8357 resolve_diagnostics_config(monitor_cfg)
8358 except ValueError as e:
8359 errors.append(f" {monitor_path}: {e}")
8360 try:
8362 except ValueError as e:
8363 errors.append(f" {monitor_path}: {e}")
8364 try:
8366 except ValueError as e:
8367 errors.append(f" {monitor_path}: {e}")
8368 statistics_console_freq = io_cfg.get('statistics_console_output_frequency')
8369 if statistics_console_freq is not None and (
8370 not isinstance(statistics_console_freq, int)
8371 or isinstance(statistics_console_freq, bool)
8372 or statistics_console_freq < 0):
8373 errors.append(
8374 f" {monitor_path}: 'io.statistics_console_output_frequency' must be a non-negative "
8375 f"integer (got {statistics_console_freq})."
8376 )
8377 try:
8378 normalize_field_statistics_config(monitor_cfg, case_cfg)
8379 except ValueError as e:
8380 errors.append(f" {monitor_path}: {e}")
8381
8382 if not errors:
8383 if needs_restart_source(case_cfg, solver_cfg):
8384 warnings.append(
8385 f"{case_path}: This configuration requires restart data (start_step > 0, "
8386 "eulerian_field_source='load', or particle restart_mode='load'). "
8387 "Use --restart-from or --continue when running."
8388 )
8389
8390 if errors:
8392 for warning in warnings:
8393 print(f"[WARN] {warning}", file=sys.stderr)
8394
8395
8396def check_post_checkpoint_cadence_alignment(post_cfg: dict, monitor_cfg: dict, post_path: str,
8397 monitor_path: str = "monitor.yml") -> "tuple[list, list]":
8398 """!
8399 @brief Report post step selections that cannot land on a committed checkpoint.
8400
8401 @details The post-processor reads committed bundles, and the solver commits one
8402 every `io.data_output_frequency` completed steps. A `step_interval` that
8403 is not a multiple of that cadence therefore asks for steps that were
8404 never written: the source-frontier scan stops at the first missing one
8405 and processes far less than the recipe requested. That is only
8406 discovered after a solve has already run, so it is caught here instead.
8407
8408 The solver also commits the initial and final states off cadence, which
8409 is why a misaligned `start_step` is a warning rather than an error: it
8410 may legitimately be the run's own starting step. `step_interval` has no
8411 such exemption, because a stride off the cadence cannot land on two
8412 consecutive checkpoints whatever the run's bounds are.
8413
8414 @param[in] post_cfg Parsed post-processing configuration.
8415 @param[in] monitor_cfg Parsed monitor configuration governing the source run.
8416 @param[in] post_path Path to the post file, for error messages.
8417 @param[in] monitor_path Path to the monitor file, for error messages.
8418 @return `(errors, warnings)`, each a list of message strings. Both are empty when
8419 the comparison cannot be made, because the inputs that would make it
8420 meaningful are validated and reported elsewhere.
8421 """
8422 errors = []
8423 warnings = []
8424 if not isinstance(post_cfg, dict) or not isinstance(monitor_cfg, dict):
8425 return errors, warnings
8426
8427 io_cfg = monitor_cfg.get("io") or {}
8428 if not isinstance(io_cfg, dict):
8429 return errors, warnings
8430 try:
8431 cadence = int(io_cfg["data_output_frequency"])
8432 except (KeyError, TypeError, ValueError):
8433 # A missing or malformed cadence is reported by the monitor's own checks.
8434 return errors, warnings
8435 # A disabled cadence commits only the initial and final states, so there is no
8436 # stride to align to.
8437 if cadence <= 0:
8438 return errors, warnings
8439
8440 try:
8441 step_interval = int(get_post_run_control_value(post_cfg, "step_interval", 1))
8442 start_step = int(get_post_run_control_value(post_cfg, "start_step", 0))
8443 except (TypeError, ValueError):
8444 # Reported by the run_control checks, which run against the same values.
8445 return errors, warnings
8446
8447 if step_interval > 0 and step_interval % cadence != 0:
8448 suggestion = max(cadence, (step_interval // cadence) * cadence)
8449 errors.append(
8450 f" {post_path}: 'run_control.step_interval' is {step_interval}, which is not a "
8451 f"multiple of 'io.data_output_frequency' ({cadence}) in {monitor_path}. The solver "
8452 f"only commits a checkpoint every {cadence} steps, so most requested steps were "
8453 f"never written and post-processing would stop at the first missing one. Use "
8454 f"{suggestion}, or another multiple of {cadence}, or lower the monitor cadence."
8455 )
8456
8457 if start_step > 0 and start_step % cadence != 0:
8458 warnings.append(
8459 f"{post_path}: 'run_control.start_step' is {start_step}, which is not a multiple of "
8460 f"'io.data_output_frequency' ({cadence}) in {monitor_path}. That step only exists if "
8461 f"it is the run's own starting step, which is committed off cadence."
8462 )
8463
8464 return errors, warnings
8465
8466
8467def validate_post_config(post_cfg: dict, post_path: str, monitor_cfg: dict = None, case_cfg: dict = None):
8468 """!
8469 @brief Validates the post-processing config before running the post-processor.
8470 @param[in] post_cfg Parsed post-processing YAML dictionary.
8471 @param[in] post_path Path to post file (for error messages).
8472 @param[in] monitor_cfg Parsed monitor configuration, when available. Two checks
8473 span both files: field statistics, where post.yml names
8474 the windows and monitor.yml decides what each accumulates,
8475 and step cadence, where monitor.yml decides which steps
8476 exist to be read. Both run only when the monitor is known.
8477 @param[in] case_cfg Parsed case configuration, when available. Spectra
8478 preconditions need the boundary conditions and block count, so
8479 they are checked only when the case is known; validating a
8480 recipe on its own still checks it on its own terms.
8481 @throws SystemExit on validation failure.
8482 """
8483 errors = []
8484 warnings = []
8485
8486 _validate_yaml_schema_keys(post_cfg, _POST_SCHEMA, post_path, errors)
8487
8488 try:
8490 except ValueError as e:
8491 recipe = None
8492 errors.append(f" {post_path}: {e}")
8493
8494 try:
8495 spectra_recipe = normalize_post_spectra_config(post_cfg)
8496 except ValueError as e:
8497 spectra_recipe = None
8498 errors.append(f" {post_path}: {e}")
8499 if spectra_recipe and spectra_recipe["tasks"] and case_cfg is not None:
8500 errors.extend(validate_post_spectra_preconditions(spectra_recipe, case_cfg, post_path))
8501 if recipe and recipe["windows"] and monitor_cfg is not None:
8502 try:
8503 configured = normalize_field_statistics_config(monitor_cfg)
8504 except ValueError:
8505 # The monitor file reports its own errors; do not repeat them here.
8506 configured = None
8507 if configured is not None:
8508 if not configured["enabled"]:
8509 errors.append(
8510 f" {post_path}: field statistics are requested, but "
8511 "'field_statistics.enabled' is not set in the monitor configuration, so no "
8512 "window is accumulated."
8513 )
8514 else:
8515 by_name = {window["name"]: window for window in configured["windows"]}
8516 for name in recipe["windows"]:
8517 if name not in by_name:
8518 errors.append(
8519 f" {post_path}: field-statistics window '{name}' is not defined in "
8520 f"the monitor configuration. Defined windows: {sorted(by_name)}."
8521 )
8522 elif _post_window_derived_field_count(by_name[name], recipe["outputs"]) == 0:
8523 errors.append(
8524 f" {post_path}: outputs {recipe['outputs']} produce no field for "
8525 f"window '{name}'; it accumulates none of the state they need. Add "
8526 "'second' to a field's moments for stresses, RMS, or turbulent "
8527 "kinetic energy, or a covariance for a flux."
8528 )
8529
8530 if not isinstance(post_cfg, dict) or not post_cfg:
8531 errors.append(f" {post_path}: post-processing config is empty or not a valid YAML mapping.")
8533
8534 # --- run_control ---
8535 if 'run_control' not in post_cfg:
8536 errors.append(f" {post_path}: missing required section 'run_control'.")
8537 else:
8538 rc = post_cfg.get('run_control', {})
8539 if not isinstance(rc, dict):
8540 errors.append(f" {post_path}: 'run_control' must be a mapping.")
8541 else:
8542 for canonical_key, aliases in POST_RUN_CONTROL_ALIASES.items():
8543 if not any(alias in rc for alias in aliases):
8544 alias_list = "', '".join(aliases)
8545 errors.append(
8546 f" {post_path}: missing required key 'run_control.{canonical_key}' "
8547 f"(accepted aliases: '{alias_list}')."
8548 )
8549 continue
8550 raw_value = _mapping_value_with_aliases(rc, *aliases)
8551 try:
8552 int(raw_value)
8553 except (TypeError, ValueError):
8554 alias_name = next((alias for alias in aliases if alias in rc), canonical_key)
8555 errors.append(
8556 f" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
8557 )
8558
8559 # --- io section ---
8560 io_cfg = post_cfg.get('io', {})
8561 source_cfg = post_cfg.get('source_data')
8562 if source_cfg is not None and not isinstance(source_cfg, dict):
8563 errors.append(f" {post_path}: 'source_data' must be a mapping when provided.")
8564 global_ops = post_cfg.get('global_operations')
8565 if global_ops is not None:
8566 if not isinstance(global_ops, dict):
8567 errors.append(f" {post_path}: 'global_operations' must be a mapping when provided.")
8568 elif 'dimensionalize' in global_ops and not isinstance(global_ops.get('dimensionalize'), bool):
8569 errors.append(f" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
8570 if not io_cfg:
8571 errors.append(f" {post_path}: missing required section 'io'.")
8572 elif not isinstance(io_cfg, dict):
8573 errors.append(f" {post_path}: 'io' must be a mapping.")
8574 else:
8575 for k in ['output_filename_prefix']:
8576 if k not in io_cfg:
8577 errors.append(f" {post_path}: missing required key 'io.{k}'.")
8578 for key_name in ('output_directory', 'output_filename_prefix', 'particle_filename_prefix'):
8579 if key_name in io_cfg and not isinstance(io_cfg.get(key_name), str):
8580 errors.append(f" {post_path}: 'io.{key_name}' must be a string when provided.")
8581 if 'output_particles' in io_cfg and not isinstance(io_cfg.get('output_particles'), bool):
8582 errors.append(f" {post_path}: 'io.output_particles' must be a boolean when provided.")
8583 particle_subsampling_frequency = io_cfg.get('particle_subsampling_frequency')
8584 if particle_subsampling_frequency is not None:
8585 if not isinstance(particle_subsampling_frequency, int) or particle_subsampling_frequency <= 0:
8586 errors.append(
8587 f" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
8588 )
8589 input_extensions = io_cfg.get('input_extensions')
8590 source_input_extensions = get_post_source_data(post_cfg).get('input_extensions')
8591 if input_extensions is not None:
8592 if not isinstance(input_extensions, dict):
8593 errors.append(f" {post_path}: 'io.input_extensions' must be a mapping when provided.")
8594 else:
8595 for ext_key in ('eulerian', 'particle'):
8596 ext_val = input_extensions.get(ext_key)
8597 if ext_val is not None and not isinstance(ext_val, str):
8598 errors.append(f" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
8599 elif ext_val is not None and str(ext_val).strip().lstrip('.').lower() != 'dat':
8600 errors.append(
8601 f" {post_path}: 'io.input_extensions.{ext_key}' must be 'dat'; "
8602 "committed checkpoint payload names are fixed."
8603 )
8604 if source_input_extensions is not None:
8605 if not isinstance(source_input_extensions, dict):
8606 errors.append(f" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
8607 else:
8608 for ext_key in ('eulerian', 'particle'):
8609 ext_val = source_input_extensions.get(ext_key)
8610 if ext_val is not None and not isinstance(ext_val, str):
8611 errors.append(
8612 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
8613 )
8614 elif ext_val is not None and str(ext_val).strip().lstrip('.').lower() != 'dat':
8615 errors.append(
8616 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be 'dat'; "
8617 "committed checkpoint payload names are fixed."
8618 )
8619
8620 for list_key in ('eulerian_fields', 'particle_fields'):
8621 list_val = io_cfg.get(list_key)
8622 if list_val is not None and not isinstance(list_val, list):
8623 errors.append(f" {post_path}: 'io.{list_key}' must be a list when provided.")
8624
8625 # --- Check eulerian_pipeline entries have 'task' key ---
8626 eulerian_pipeline = post_cfg.get('eulerian_pipeline', [])
8627 if eulerian_pipeline is not None and not isinstance(eulerian_pipeline, list):
8628 errors.append(f" {post_path}: 'eulerian_pipeline' must be a list when provided.")
8629 eulerian_pipeline = []
8630 for i, entry in enumerate(eulerian_pipeline):
8631 if not isinstance(entry, dict) or 'task' not in entry:
8632 errors.append(f" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
8633 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
8634 continue
8635 task_name = entry.get('task')
8636 if task_name == 'q_criterion':
8637 continue
8638 if task_name == 'nodal_average':
8639 in_field = entry.get('input_field')
8640 out_field = entry.get('output_field')
8641 if not isinstance(in_field, str) or not in_field.strip():
8642 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
8643 if not isinstance(out_field, str) or not out_field.strip():
8644 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
8645 if isinstance(in_field, str) and isinstance(out_field, str) and in_field == out_field:
8646 errors.append(
8647 f" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
8648 )
8649 continue
8650 if task_name == 'normalize_field':
8651 field = entry.get('field', 'P')
8652 if not isinstance(field, str) or not field.strip():
8653 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
8654 elif field != 'P':
8655 errors.append(
8656 f" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
8657 f"(got '{field}')."
8658 )
8659 reference_point = entry.get('reference_point', [1, 1, 1])
8660 if not isinstance(reference_point, (list, tuple)) or len(reference_point) != 3:
8661 errors.append(
8662 f" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
8663 )
8664 else:
8665 for rp_idx, coord in enumerate(reference_point):
8666 try:
8667 int(coord)
8668 except (TypeError, ValueError):
8669 errors.append(
8670 f" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
8671 "must be integer-compatible."
8672 )
8673 continue
8674 errors.append(
8675 f" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]. "
8676 f"Available tasks: {list(POST_EULERIAN_PIPELINE_TASKS)}."
8677 )
8678
8679 # --- Check lagrangian_pipeline entries have 'task' key ---
8680 lagrangian_pipeline = post_cfg.get('lagrangian_pipeline', [])
8681 if lagrangian_pipeline is not None and not isinstance(lagrangian_pipeline, list):
8682 errors.append(f" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
8683 lagrangian_pipeline = []
8684 for i, entry in enumerate(lagrangian_pipeline):
8685 if not isinstance(entry, dict) or 'task' not in entry:
8686 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
8687 continue
8688 task_name = entry.get('task')
8689 if task_name == 'specific_ke':
8690 in_field = entry.get('input_field')
8691 out_field = entry.get('output_field')
8692 if not isinstance(in_field, str) or not in_field.strip():
8693 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
8694 if not isinstance(out_field, str) or not out_field.strip():
8695 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
8696 continue
8697 errors.append(
8698 f" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
8699 )
8700
8701 # --- Check statistics pipeline entries ---
8702 stats_cfg = post_cfg.get('statistics_pipeline')
8703 stats_entries = []
8704 if stats_cfg is not None:
8705 if isinstance(stats_cfg, list):
8706 stats_entries = stats_cfg
8707 elif isinstance(stats_cfg, dict):
8708 stats_entries = stats_cfg.get('tasks', [])
8709 if not isinstance(stats_entries, list):
8710 errors.append(f" {post_path}: 'statistics_pipeline.tasks' must be a list.")
8711 stats_output_prefix = stats_cfg.get('output_prefix')
8712 if stats_output_prefix is not None and not isinstance(stats_output_prefix, str):
8713 errors.append(f" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
8714 else:
8715 errors.append(
8716 f" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
8717 )
8718 for i, entry in enumerate(stats_entries):
8719 if isinstance(entry, str):
8720 task_name = entry
8721 elif isinstance(entry, dict) and 'task' in entry:
8722 task_name = entry.get('task')
8723 else:
8724 errors.append(
8725 f" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
8726 )
8727 continue
8728 try:
8729 normalize_statistics_task(task_name)
8730 except ValueError as e:
8731 errors.append(f" {post_path}: {e}")
8732
8733 legacy_stats_output_prefix = post_cfg.get('statistics_output_prefix')
8734 if legacy_stats_output_prefix is not None and not isinstance(legacy_stats_output_prefix, str):
8735 errors.append(f" {post_path}: 'statistics_output_prefix' must be a string when provided.")
8736
8737 # Runs last so it reports against values the run_control checks above have
8738 # already established are integers.
8739 cadence_errors, cadence_warnings = check_post_checkpoint_cadence_alignment(
8740 post_cfg, monitor_cfg, post_path)
8741 errors.extend(cadence_errors)
8742 warnings.extend(cadence_warnings)
8743
8744 if errors:
8746 for warning in warnings:
8747 print(f"[WARN] {warning}", file=sys.stderr)
8748
8749def validate_cluster_config(cluster_cfg: dict, cluster_path: str):
8750 """!
8751 @brief Validate Slurm scheduler configuration from cluster.yml.
8752 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
8753 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
8754 """
8755 errors = []
8756 warnings = []
8757 _validate_yaml_schema_keys(cluster_cfg, _CLUSTER_SCHEMA, cluster_path, errors)
8758 if not isinstance(cluster_cfg, dict) or not cluster_cfg:
8759 errors.append(f" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
8761
8762 scheduler = cluster_cfg.get("scheduler", {})
8763 if not isinstance(scheduler, dict):
8764 errors.append(f" {cluster_path}: 'scheduler' must be a mapping.")
8765 else:
8766 scheduler_type = scheduler.get("type", "slurm")
8767 if str(scheduler_type).lower() != "slurm":
8768 errors.append(f" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
8769
8770 resources = cluster_cfg.get("resources", {})
8771 if not isinstance(resources, dict):
8772 errors.append(f" {cluster_path}: 'resources' must be a mapping.")
8773 else:
8774 for req in ("account", "nodes", "ntasks_per_node", "mem", "time"):
8775 if req not in resources:
8776 errors.append(f" {cluster_path}: missing required key 'resources.{req}'.")
8777 for int_key in ("nodes", "ntasks_per_node"):
8778 if int_key in resources:
8779 val = resources.get(int_key)
8780 if not isinstance(val, int) or val <= 0:
8781 errors.append(f" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
8782 for str_key in ("account", "mem", "time", "partition"):
8783 if str_key in resources and resources.get(str_key) is not None:
8784 if not isinstance(resources.get(str_key), str):
8785 errors.append(f" {cluster_path}: resources.{str_key} must be a string when provided.")
8786 if isinstance(resources.get("time"), str):
8787 try:
8788 parse_slurm_time_limit_to_seconds(resources["time"])
8789 except ValueError as exc:
8790 errors.append(
8791 f" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
8792 )
8793 account = resources.get("account")
8794 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
8795 warnings.append(
8796 f"{cluster_path}: resources.account still uses the sample placeholder "
8797 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
8798 )
8799
8800 notifications = cluster_cfg.get("notifications", {})
8801 if notifications is not None and not isinstance(notifications, dict):
8802 errors.append(f" {cluster_path}: 'notifications' must be a mapping when provided.")
8803 elif isinstance(notifications, dict):
8804 mail_user = notifications.get("mail_user")
8805 if mail_user is not None and not is_valid_email(mail_user):
8806 errors.append(f" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
8807 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
8808 warnings.append(
8809 f"{cluster_path}: notifications.mail_user still uses the sample placeholder "
8810 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
8811 )
8812 mail_type = notifications.get("mail_type")
8813 if mail_type is not None and not isinstance(mail_type, str):
8814 errors.append(f" {cluster_path}: notifications.mail_type must be a string when provided.")
8815
8816 execution = cluster_cfg.get("execution", {})
8817 if execution is not None and not isinstance(execution, dict):
8818 errors.append(f" {cluster_path}: 'execution' must be a mapping when provided.")
8819 elif isinstance(execution, dict):
8820 module_setup = execution.get("module_setup", [])
8821 if module_setup is not None and not isinstance(module_setup, list):
8822 errors.append(f" {cluster_path}: execution.module_setup must be a list of shell lines.")
8823 elif isinstance(module_setup, list):
8824 for i, line in enumerate(module_setup):
8825 if not isinstance(line, str):
8826 errors.append(f" {cluster_path}: execution.module_setup[{i}] must be a string.")
8827
8828 launcher = execution.get("launcher")
8829 if launcher is not None and not isinstance(launcher, str):
8830 errors.append(f" {cluster_path}: execution.launcher must be a string when provided.")
8831 launcher_args = execution.get("launcher_args")
8832 if launcher_args is not None and not isinstance(launcher_args, list):
8833 errors.append(f" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
8834 elif isinstance(launcher_args, list):
8835 for i, token in enumerate(launcher_args):
8836 if not isinstance(token, (str, int, float)):
8837 errors.append(f" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
8839 errors.append(
8840 f" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
8841 "split whitespace-separated arguments into separate list items."
8842 )
8843 if (launcher is None or isinstance(launcher, str)) and (launcher_args is None or isinstance(launcher_args, list)):
8844 try:
8846 except ValueError as exc:
8847 errors.append(f" {cluster_path}: {exc}.")
8848
8849 extra_sbatch = execution.get("extra_sbatch")
8850 if extra_sbatch is not None and not isinstance(extra_sbatch, (dict, list)):
8851 errors.append(f" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
8852
8853 walltime_guard = execution.get("walltime_guard")
8854 if walltime_guard is not None and not isinstance(walltime_guard, dict):
8855 errors.append(f" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
8856 elif isinstance(walltime_guard, dict):
8857 enabled = walltime_guard.get("enabled")
8858 if enabled is not None and not isinstance(enabled, bool):
8859 errors.append(f" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
8860
8861 warmup_steps = walltime_guard.get("warmup_steps")
8862 if warmup_steps is not None and (not isinstance(warmup_steps, int) or isinstance(warmup_steps, bool) or warmup_steps <= 0):
8863 errors.append(
8864 f" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
8865 )
8866
8867 multiplier = walltime_guard.get("multiplier")
8868 if multiplier is not None:
8869 if isinstance(multiplier, bool) or not isinstance(multiplier, (int, float)) or multiplier <= 0.0:
8870 errors.append(
8871 f" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
8872 )
8873 elif float(multiplier) > 5.0:
8874 errors.append(
8875 f" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
8876 )
8877
8878 min_seconds = walltime_guard.get("min_seconds")
8879 if min_seconds is not None and (
8880 isinstance(min_seconds, bool) or not isinstance(min_seconds, (int, float)) or float(min_seconds) <= 0.0
8881 ):
8882 errors.append(
8883 f" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
8884 )
8885
8886 estimator_alpha = walltime_guard.get("estimator_alpha")
8887 if estimator_alpha is not None:
8888 if isinstance(estimator_alpha, bool) or not isinstance(estimator_alpha, (int, float)):
8889 errors.append(
8890 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
8891 )
8892 elif float(estimator_alpha) <= 0.0 or float(estimator_alpha) > 1.0:
8893 errors.append(
8894 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
8895 )
8896
8897 if warnings:
8898 for warning in warnings:
8899 print(f"[WARN] {warning}", file=sys.stderr)
8900
8901 if errors:
8903
8904def validate_study_config(study_cfg: dict, study_path: str, skip_base_file_check: bool = False):
8905 """!
8906 @brief Validate sweep/study specification from study.yml.
8907 @param[in] study_cfg Argument passed to `validate_study_config()`.
8908 @param[in] study_path Argument passed to `validate_study_config()`.
8909 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
8910 """
8911 errors = []
8912 _validate_yaml_schema_keys(study_cfg, _STUDY_SCHEMA, study_path, errors)
8913 if not isinstance(study_cfg, dict) or not study_cfg:
8914 errors.append(f" {study_path}: study config is empty or not a valid YAML mapping.")
8916
8917 base_cfgs = study_cfg.get("base_configs")
8918 if not isinstance(base_cfgs, dict):
8919 errors.append(f" {study_path}: missing required mapping 'base_configs'.")
8920 else:
8921 for req in ("case", "solver", "monitor", "post"):
8922 path_val = base_cfgs.get(req)
8923 if not path_val or not isinstance(path_val, str):
8924 errors.append(f" {study_path}: base_configs.{req} must be a path string.")
8925 elif not skip_base_file_check:
8926 resolved = resolve_path(study_path, path_val)
8927 if not os.path.isfile(resolved):
8928 errors.append(f" {study_path}: base_configs.{req} does not exist: {resolved}")
8929
8930 study_type = study_cfg.get("study_type")
8931 allowed_types = set(STUDY_TYPES)
8932 if study_type not in allowed_types:
8933 errors.append(
8934 f" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
8935 )
8936
8937 parameters = study_cfg.get("parameters")
8938 parameter_sets = study_cfg.get("parameter_sets")
8939 allowed_roots = {"case", "solver", "monitor", "post"}
8940 if bool(parameters) == bool(parameter_sets):
8941 errors.append(f" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
8942 elif parameter_sets:
8943 if not isinstance(parameter_sets, list) or not parameter_sets:
8944 errors.append(f" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
8945 else:
8946 for set_index, param_set in enumerate(parameter_sets):
8947 if not isinstance(param_set, dict) or not param_set:
8948 errors.append(
8949 f" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
8950 )
8951 continue
8952 for key, value in param_set.items():
8953 if not isinstance(key, str) or "." not in key:
8954 errors.append(
8955 f" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
8956 )
8957 continue
8958 root = key.split(".", 1)[0]
8959 if root not in allowed_roots:
8960 errors.append(
8961 f" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
8962 )
8963 if isinstance(value, (dict, list)):
8964 errors.append(
8965 f" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
8966 )
8967 else:
8968 if not isinstance(parameters, dict) or not parameters:
8969 errors.append(f" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
8970 else:
8971 for key, values in parameters.items():
8972 if not isinstance(key, str) or "." not in key:
8973 errors.append(
8974 f" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
8975 )
8976 continue
8977 root = key.split(".", 1)[0]
8978 if root not in allowed_roots:
8979 errors.append(
8980 f" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
8981 )
8982 if not isinstance(values, list) or len(values) == 0:
8983 errors.append(f" {study_path}: parameters.{key} must be a non-empty list.")
8984
8985 metrics = study_cfg.get("metrics", [])
8986 if metrics is not None and not isinstance(metrics, list):
8987 errors.append(f" {study_path}: 'metrics' must be a list when provided.")
8988 elif isinstance(metrics, list):
8989 for i, metric in enumerate(metrics):
8990 if isinstance(metric, str):
8991 continue
8992 if not isinstance(metric, dict):
8993 errors.append(
8994 f" {study_path}: metrics[{i}] must be a string or mapping."
8995 )
8996 continue
8997 if "name" not in metric:
8998 errors.append(f" {study_path}: metrics[{i}] missing required key 'name'.")
8999 if "source" not in metric:
9000 errors.append(f" {study_path}: metrics[{i}] missing required key 'source'.")
9001 for label_key in ("plot_label", "label", "units"):
9002 label_value = metric.get(label_key)
9003 if label_value is not None and (not isinstance(label_value, str) or not label_value.strip()):
9004 errors.append(
9005 f" {study_path}: metrics[{i}].{label_key} must be a non-empty string when provided."
9006 )
9007
9008 plotting = study_cfg.get("plotting", {})
9009 if plotting is not None and not isinstance(plotting, dict):
9010 errors.append(f" {study_path}: 'plotting' must be a mapping when provided.")
9011 elif isinstance(plotting, dict):
9012 enabled = plotting.get("enabled")
9013 if enabled is not None and not isinstance(enabled, bool):
9014 errors.append(f" {study_path}: plotting.enabled must be boolean when provided.")
9015 output_format = plotting.get("output_format")
9016 if output_format is not None and output_format not in STUDY_PLOT_FORMATS:
9017 errors.append(f" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
9018
9019 execution = study_cfg.get("execution", {})
9020 if execution is not None and not isinstance(execution, dict):
9021 errors.append(f" {study_path}: 'execution' must be a mapping when provided.")
9022 elif isinstance(execution, dict):
9023 max_conc = execution.get("max_concurrent_array_tasks")
9024 if max_conc is not None and (not isinstance(max_conc, int) or max_conc <= 0):
9025 errors.append(
9026 f" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
9027 )
9028
9029 if errors:
9031
9032def _deep_set(container: dict, dotted_path: str, value):
9033 """!
9034 @brief Set nested dictionary value, creating intermediate maps when needed.
9035 @param[in] container Argument passed to `_deep_set()`.
9036 @param[in] dotted_path Argument passed to `_deep_set()`.
9037 @param[in] value Argument passed to `_deep_set()`.
9038 """
9039 keys = dotted_path.split(".")
9040 current = container
9041 for key in keys[:-1]:
9042 if key not in current or not isinstance(current[key], dict):
9043 current[key] = {}
9044 current = current[key]
9045 current[keys[-1]] = value
9046
9047def expand_parameter_matrix(parameters: dict) -> list:
9048 """!
9049 @brief Expand study parameter lists into cartesian-product combinations.
9050 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
9051 @return Value returned by `expand_parameter_matrix()`.
9052 """
9053 param_keys = list(parameters.keys())
9054 all_values = [parameters[k] for k in param_keys]
9055 combos = []
9056 for combo in itertools.product(*all_values):
9057 combos.append(dict(zip(param_keys, combo)))
9058 return combos
9059
9060
9061def expand_study_parameter_combinations(study_cfg: dict) -> list:
9062 """!
9063 @brief Expand either cartesian-study parameters or explicit parameter sets.
9064 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
9065 @return Value returned by `expand_study_parameter_combinations()`.
9066 """
9067 parameter_sets = study_cfg.get("parameter_sets")
9068 if parameter_sets:
9069 return [dict(param_set) for param_set in parameter_sets]
9070 return expand_parameter_matrix(study_cfg.get("parameters") or {})
9071
9072
9073def flatten_study_parameters(parameters: dict) -> dict:
9074 """!
9075 @brief Flatten grouped study overrides into scalar dotted-path columns.
9076
9077 @details A grouped override such as `case.run_control: {dt_physical: ...}`
9078 materializes correctly in case YAML but is not a useful CSV cell or
9079 plot coordinate. Flattening preserves the actual varied variables.
9080
9081 @param[in] parameters One expanded study parameter combination.
9082 @return Flat dotted-path-to-scalar mapping.
9083 """
9084 flattened = {}
9085
9086 def visit(prefix, value):
9087 """!
9088 @brief Recursively flatten one grouped override value.
9089 @param[in] prefix Current dotted parameter path.
9090 @param[in] value Scalar or nested mapping at the current path.
9091 """
9092 if isinstance(value, dict):
9093 for child, child_value in value.items():
9094 visit(f"{prefix}.{child}" if prefix else str(child), child_value)
9095 else:
9096 flattened[prefix] = value
9097
9098 for key, value in (parameters or {}).items():
9099 visit(str(key), value)
9100 return flattened
9101
9102
9103def get_study_parameter_keys(study_cfg: dict) -> list:
9104 """!
9105 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
9106 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
9107 @return Value returned by `get_study_parameter_keys()`.
9108 """
9109 parameters = study_cfg.get("parameters")
9110 if isinstance(parameters, dict) and parameters:
9111 keys = []
9112 for key, candidates in parameters.items():
9113 candidate_dicts = [value for value in candidates if isinstance(value, dict)] if isinstance(candidates, list) else []
9114 expanded = []
9115 for value in candidate_dicts:
9116 for flat_key in flatten_study_parameters({key: value}):
9117 if flat_key not in expanded:
9118 expanded.append(flat_key)
9119 for flat_key in expanded or [key]:
9120 if flat_key not in keys:
9121 keys.append(flat_key)
9122 return keys
9123
9124 keys = []
9125 parameter_sets = study_cfg.get("parameter_sets") or []
9126 for param_set in parameter_sets:
9127 if not isinstance(param_set, dict):
9128 continue
9129 for key in flatten_study_parameters(param_set):
9130 if key not in keys:
9131 keys.append(key)
9132 return keys
9133
9134
9135def get_cluster_total_tasks(cluster_cfg: dict) -> int:
9136 """!
9137 @brief Return cluster total tasks.
9138 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
9139 @return Value returned by `get_cluster_total_tasks()`.
9140 """
9141 resources = cluster_cfg.get("resources", {})
9142 return int(resources.get("nodes", 1)) * int(resources.get("ntasks_per_node", 1))
9143
9144def normalize_extension(ext: str) -> str:
9145 """!
9146 @brief Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
9147 @param[in] ext Argument passed to `normalize_extension()`.
9148 @return Value returned by `normalize_extension()`.
9149 """
9150 if ext is None:
9151 return None
9152 return str(ext).strip().lstrip(".")
9153
9155 """!
9156 @brief Resolve how to invoke this conductor again from a batch script.
9157
9158 @details Prefers the `bin/picurv` wrapper, which selects the managed Python
9159 environment a cluster node needs, and falls back to the running
9160 interpreter with the package entry point when that wrapper is absent.
9161
9162 @return Argv prefix that re-invokes the conductor.
9163 """
9164 wrapper = os.path.join(PACKAGE_PROJECT_ROOT, "bin", "picurv")
9165 if os.path.isfile(wrapper) and os.access(wrapper, os.X_OK):
9166 return [wrapper]
9167 return [sys.executable, os.path.join(PACKAGE_PROJECT_ROOT, "picurv_cli", "picurv")]
9168
9169
9170def build_spectra_follow_command(run_dir: str, post_path: str, post_cfg: dict) -> list:
9171 """!
9172 @brief Build the batch-script step that measures spectra after the field stage.
9173
9174 @details Spectra are a serial pass over committed checkpoints, so the command is
9175 returned bare rather than wrapped in the MPI launcher: run under `srun`
9176 with the post stage's task count it would become one identical copy per
9177 task, each writing the same files.
9178
9179 @param[in] run_dir Run directory the batch job operates on.
9180 @param[in] post_path Post recipe path, reachable from the compute node.
9181 @param[in] post_cfg Effective post configuration.
9182 @return Argv list, or an empty list when the recipe requests no spectra.
9183 """
9184 try:
9185 spectra = normalize_post_spectra_config(post_cfg)
9186 except ValueError:
9187 # Validation reports recipe errors; do not raise a second time here.
9188 return []
9189 if not spectra["tasks"]:
9190 return []
9192 "run", "--post-process", "--only", "spectra",
9193 "--run-dir", os.path.abspath(run_dir),
9194 "--post", os.path.abspath(post_path),
9195 ]
9196
9197
9199 script_path: str,
9200 job_name: str,
9201 cluster_cfg: dict,
9202 command: list,
9203 workdir: str,
9204 stdout_path: str,
9205 stderr_path: str = None,
9206 env_vars: dict = None,
9207 shell_env_vars: dict = None,
9208 array_spec: str = None,
9209 follow_commands: list = None
9210):
9211 """!
9212 @brief Render a Slurm batch script for a single command.
9213 @param[in] script_path Argument passed to `render_slurm_script()`.
9214 @param[in] job_name Argument passed to `render_slurm_script()`.
9215 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
9216 @param[in] command Argument passed to `render_slurm_script()`.
9217 @param[in] workdir Argument passed to `render_slurm_script()`.
9218 @param[in] stdout_path Argument passed to `render_slurm_script()`.
9219 @param[in] stderr_path Argument passed to `render_slurm_script()`.
9220 @param[in] env_vars Argument passed to `render_slurm_script()`.
9221 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
9222 @param[in] array_spec Argument passed to `render_slurm_script()`.
9223 @param[in] follow_commands Commands to run after the launched one, each an argv
9224 list. They run in the batch shell rather than under the
9225 MPI launcher, so a serial step does not become one copy
9226 per task. Supplying any of them drops the `exec`.
9227 """
9228 resources = cluster_cfg.get("resources", {})
9229 notifications = cluster_cfg.get("notifications", {}) or {}
9230 execution = cluster_cfg.get("execution", {}) or {}
9231 extra_sbatch = execution.get("extra_sbatch")
9232 module_setup = execution.get("module_setup", []) or []
9233
9234 if stderr_path is None:
9235 stderr_path = stdout_path.replace(".out", ".err")
9236
9237 lines = [
9238 "#!/bin/bash",
9239 f"#SBATCH --job-name={job_name}",
9240 f"#SBATCH --nodes={resources['nodes']}",
9241 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
9242 f"#SBATCH --mem={resources['mem']}",
9243 f"#SBATCH --time={resources['time']}",
9244 f"#SBATCH --output={stdout_path}",
9245 f"#SBATCH --error={stderr_path}",
9246 f"#SBATCH --account={resources['account']}",
9247 ]
9248 partition = resources.get("partition")
9249 if partition:
9250 lines.append(f"#SBATCH --partition={partition}")
9251 if array_spec:
9252 lines.append(f"#SBATCH --array={array_spec}")
9253 mail_user = notifications.get("mail_user")
9254 mail_type = notifications.get("mail_type")
9255 if mail_user:
9256 lines.append(f"#SBATCH --mail-user={mail_user}")
9257 if mail_type:
9258 lines.append(f"#SBATCH --mail-type={mail_type}")
9259
9260 if isinstance(extra_sbatch, dict):
9261 for key, value in extra_sbatch.items():
9262 flag = str(key)
9263 if not flag.startswith("--"):
9264 flag = f"--{flag}"
9265 if isinstance(value, bool):
9266 if value:
9267 lines.append(f"#SBATCH {flag}")
9268 elif value is not None:
9269 lines.append(f"#SBATCH {flag}={value}")
9270 elif isinstance(extra_sbatch, list):
9271 for token in extra_sbatch:
9272 lines.append(f"#SBATCH {token}")
9273
9274 lines.extend(
9275 [
9276 "",
9277 "set -euo pipefail",
9278 "",
9279 f"cd {shlex.quote(workdir)}",
9280 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
9281 'echo "[$(date)] Working directory: $PWD"',
9282 ]
9283 )
9284
9285 if shell_env_vars:
9286 for key, value in shell_env_vars.items():
9287 lines.append(f"export {key}={value}")
9288
9289 for setup_line in module_setup:
9290 lines.append(str(setup_line))
9291
9292 if env_vars:
9293 for key, value in env_vars.items():
9294 lines.append(f"export {key}={shlex.quote(str(value))}")
9295
9296 cmd = " ".join(shlex.quote(str(tok)) for tok in command)
9297 if follow_commands:
9298 # `exec` would replace the shell and the trailing commands would never run.
9299 # `set -e` is already in effect, so a failure in the launched command aborts
9300 # the job before anything downstream of it executes.
9301 lines.append(cmd)
9302 for follow in follow_commands:
9303 lines.append(" ".join(shlex.quote(str(tok)) for tok in follow))
9304 else:
9305 lines.append(f"exec {cmd}")
9306
9307 os.makedirs(os.path.dirname(script_path), exist_ok=True)
9308 with open(script_path, "w") as f:
9309 f.write("\n".join(lines) + "\n")
9310 os.chmod(script_path, 0o755)
9311
9313 launcher: "str | None",
9314 launcher_args: "list | None" = None,
9315 label: str = "launcher",
9316) -> "tuple[str | None, list[str]]":
9317 """!
9318 @brief Canonicalize launcher config into executable token plus argv-style flags.
9319 @param[in] launcher Argument passed to `split_launcher_tokens()`.
9320 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
9321 @param[in] label Argument passed to `split_launcher_tokens()`.
9322 @return Value returned by `split_launcher_tokens()`.
9323 """
9324 normalized_args = [str(x) for x in (launcher_args or [])]
9325
9326 if launcher is None:
9327 return None, normalized_args
9328
9329 try:
9330 launcher_tokens = shlex.split(str(launcher))
9331 except ValueError as exc:
9332 raise ValueError(f"{label} is not shell-parseable: {exc}") from exc
9333
9334 if not launcher_tokens:
9335 return None, normalized_args
9336
9337 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
9338
9339
9340def normalize_cluster_launcher(execution: dict) -> "tuple[str | None, list[str]]":
9341 """!
9342 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
9343 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
9344 @return Value returned by `normalize_cluster_launcher()`.
9345 """
9346 return split_launcher_tokens(
9347 execution.get("launcher"),
9348 execution.get("launcher_args") or [],
9349 label="execution.launcher",
9350 )
9351
9352
9353def strip_launcher_size_flags(launcher_name: str, launcher_args: "list[str]") -> "list[str]":
9354 """!
9355 @brief Remove explicit MPI task-count flags from known launchers.
9356 @param[in] launcher_name Basename-normalized launcher executable.
9357 @param[in] launcher_args Launcher argument list.
9358 @return Filtered launcher arguments with explicit size flags removed.
9359 """
9360 filtered = []
9361 idx = 0
9362 while idx < len(launcher_args):
9363 token = str(launcher_args[idx])
9364
9365 if launcher_name == "srun":
9366 if token in {"-n", "--ntasks"}:
9367 idx += 2
9368 continue
9369 if token.startswith("--ntasks="):
9370 idx += 1
9371 continue
9372 elif launcher_name in {"mpiexec", "mpirun"}:
9373 if token in {"-n", "-np"}:
9374 idx += 2
9375 continue
9376 if token.startswith("-n=") or token.startswith("-np="):
9377 idx += 1
9378 continue
9379
9380 filtered.append(token)
9381 idx += 1
9382
9383 return filtered
9384
9385
9387 executable: str,
9388 executable_args: list,
9389 num_procs: int,
9390 config_search_anchor: str = None,
9391 allow_single_rank_launcher_override: bool = False,
9392 force_num_procs: "int | None" = None,
9393) -> list:
9394 """!
9395 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
9396 @param[in] executable Argument passed to `build_local_launch_command()`.
9397 @param[in] executable_args Argument passed to `build_local_launch_command()`.
9398 @param[in] num_procs Argument passed to `build_local_launch_command()`.
9399 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
9400 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
9401 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
9402 @return Value returned by `build_local_launch_command()`.
9403 """
9404 target_num_procs = force_num_procs if force_num_procs is not None else num_procs
9405 command = [executable] + executable_args
9406 if target_num_procs <= 1 and not allow_single_rank_launcher_override:
9407 return command
9408
9409 launcher_override = os.environ.get("PICURV_MPI_LAUNCHER")
9410 if launcher_override is None:
9411 launcher_override = os.environ.get("MPI_LAUNCHER")
9412
9413 try:
9414 if launcher_override is not None:
9415 explicit_launcher_config = True
9416 launcher, launcher_args = split_launcher_tokens(
9417 launcher_override,
9418 label="local MPI launcher override",
9419 )
9420 else:
9421 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor)
9422 local_execution = resolve_runtime_execution_context(runtime_execution_cfg, "local")
9423 configured_launcher = local_execution.get("launcher")
9424 configured_args = local_execution.get("launcher_args") or []
9425 explicit_launcher_config = configured_launcher is not None or bool(configured_args)
9426 if target_num_procs <= 1 and not explicit_launcher_config:
9427 return command
9428 launcher, launcher_args = split_launcher_tokens(
9429 configured_launcher if configured_launcher is not None else "mpiexec",
9430 configured_args,
9431 label="local_execution.launcher",
9432 )
9433 except ValueError as exc:
9434 print(f"[FATAL] {exc}", file=sys.stderr)
9435 sys.exit(1)
9436
9437 if not launcher:
9438 return command
9439
9440 launcher_name = os.path.basename(launcher).lower()
9441 if force_num_procs is not None:
9442 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
9443 prefix = [launcher] + launcher_args
9444
9445 if launcher_name == "srun":
9446 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
9447 if not has_n:
9448 prefix += ["-n", str(target_num_procs)]
9449 elif launcher_name in {"mpiexec", "mpirun"}:
9450 has_n = any(token in {"-n", "-np"} for token in launcher_args)
9451 if not has_n:
9452 prefix += ["-n", str(target_num_procs)]
9453
9454 return prefix + command
9455
9456def resolve_cluster_execution(cluster_cfg: dict, config_search_anchor: str = None, extra_search_anchors=None) -> dict:
9457 """!
9458 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
9459 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
9460 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
9461 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
9462 @return Value returned by `resolve_cluster_execution()`.
9463 """
9464 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor, extra_search_anchors=extra_search_anchors)
9465 shared_cluster_execution = resolve_runtime_execution_context(runtime_execution_cfg, "cluster")
9466 execution = cluster_cfg.get("execution", {}) or {}
9467 cluster_override = {
9468 "launcher": execution.get("launcher") if "launcher" in execution else None,
9469 "launcher_args": execution.get("launcher_args") if "launcher_args" in execution else None,
9470 }
9471 return merge_execution_overrides(shared_cluster_execution, cluster_override)
9472
9473
9475 cluster_cfg: dict,
9476 executable: str,
9477 executable_args: list,
9478 config_search_anchor: str = None,
9479 extra_search_anchors=None,
9480 force_num_procs: "int | None" = None,
9481) -> list:
9482 """!
9483 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
9484 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
9485 @param[in] executable Argument passed to `build_cluster_launch_command()`.
9486 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
9487 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
9488 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
9489 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
9490 @return Value returned by `build_cluster_launch_command()`.
9491 """
9492 try:
9493 execution = resolve_cluster_execution(
9494 cluster_cfg,
9495 config_search_anchor=config_search_anchor,
9496 extra_search_anchors=extra_search_anchors,
9497 )
9498 launcher, launcher_args = split_launcher_tokens(
9499 execution.get("launcher") if execution.get("launcher") is not None else "srun",
9500 execution.get("launcher_args") or [],
9501 label="cluster execution launcher",
9502 )
9503 except ValueError as exc:
9504 print(f"[FATAL] {exc}", file=sys.stderr)
9505 sys.exit(1)
9506
9507 ntasks = int(force_num_procs) if force_num_procs is not None else get_cluster_total_tasks(cluster_cfg)
9508 launcher_name = launcher.lower() if launcher else ""
9509 if force_num_procs is not None:
9510 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
9511
9512 if launcher and launcher_name == "srun":
9513 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
9514 cmd = ["srun"] + launcher_args
9515 if not has_n:
9516 cmd += ["-n", str(ntasks)]
9517 return cmd + [executable] + executable_args
9518
9519 if launcher and launcher_name == "mpirun":
9520 has_np = any(token in {"-np", "-n"} for token in launcher_args)
9521 cmd = ["mpirun"] + launcher_args
9522 if not has_np:
9523 cmd += ["-np", str(ntasks)]
9524 return cmd + [executable] + executable_args
9525
9526 if launcher and launcher_name == "mpiexec":
9527 has_np = any(token in {"-np", "-n"} for token in launcher_args)
9528 cmd = ["mpiexec"] + launcher_args
9529 if not has_np:
9530 cmd += ["-np", str(ntasks)]
9531 return cmd + [executable] + executable_args
9532
9533 # Custom launcher or no launcher.
9534 cmd = []
9535 if launcher:
9536 cmd.append(str(launcher))
9537 cmd += launcher_args
9538 cmd += [executable] + executable_args
9539 return cmd
9540
9541def parse_slurm_job_id(sbatch_output: str) -> str:
9542 """!
9543 @brief Extract numeric job id from standard sbatch output.
9544 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
9545 @return Value returned by `parse_slurm_job_id()`.
9546 """
9547 match = re.search(r"Submitted batch job\s+(\d+)", sbatch_output or "")
9548 return match.group(1) if match else None
9549
9550def submit_sbatch(script_path: str, dependency: str = None, dependency_type: str = "afterok") -> dict:
9551 """!
9552 @brief Submit sbatch script and return submission metadata.
9553 @param[in] script_path Argument passed to `submit_sbatch()`.
9554 @param[in] dependency Argument passed to `submit_sbatch()`.
9555 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
9556 @return Value returned by `submit_sbatch()`.
9557 """
9558 cmd = ["sbatch"]
9559 if dependency:
9560 cmd.append(f"--dependency={dependency_type}:{dependency}")
9561 cmd.append(script_path)
9562 result = subprocess.run(cmd, text=True, capture_output=True, check=False)
9563 metadata = {
9564 "command": cmd,
9565 "returncode": result.returncode,
9566 "stdout": (result.stdout or "").strip(),
9567 "stderr": (result.stderr or "").strip(),
9568 "script": script_path,
9569 }
9570 if result.returncode != 0:
9571 print(f"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
9572 sys.exit(result.returncode)
9573 metadata["job_id"] = parse_slurm_job_id(metadata["stdout"])
9574 if not metadata["job_id"]:
9575 print(
9576 f"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
9577 file=sys.stderr
9578 )
9579 sys.exit(1)
9580 return metadata
9581
9582
9584 """!
9585 @brief Prints validation errors and exits.
9586 @param[in] errors List of error message strings.
9587 """
9588 print(f"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
9589 for raw_error in errors:
9590 file_path, message = _split_error_file_and_message(raw_error)
9591 key_path = _extract_key_path(message)
9592 code = _classify_error_code(message)
9593 emit_structured_error(code, key=key_path, file_path=file_path, message=message)
9594 print(
9595 "\nHint: See examples/master_template/ for valid config structure and "
9596 "docs/pages/14_Config_Contract.md for key-level contract details.",
9597 file=sys.stderr,
9598 )
9599 sys.exit(1)
9600
9601
9602def generate_header(run_id: str, source_files: dict) -> str:
9603 """!
9604 @brief Creates a standard header block for all generated files.
9605 @param[in] run_id The unique identifier for the current simulation run.
9606 @param[in] source_files A dictionary of source profile files used.
9607 @return A formatted string containing the header.
9608 """
9609 header_parts = [
9610 "# ==============================================================================",
9611 "# AUTO-GENERATED CONFIGURATION FILE",
9612 "# ------------------------------------------------------------------------------",
9613 f"# Run ID: {run_id}",
9614 f"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
9615 "#",
9616 "# Source Configuration:"
9617 ]
9618 for name, path in source_files.items():
9619 header_parts.append(f"# - {name:<12}: {os.path.basename(path)}")
9620 header_parts.extend([
9621 "#",
9622 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
9623 "# ==============================================================================\n"
9624 ])
9625 return "\n".join(header_parts)
9626
9627def generate_simple_list_file(run_dir: str, run_id: str, cfg: dict, section: str, key: str,
9628 filename: str, header_sources: dict,
9629 config_dir: str = None) -> str:
9630 """!
9631 @brief Generic function to create a file containing a simple list of strings.
9632 @param[in] run_dir The path to the main run directory.
9633 @param[in] run_id The unique identifier for the run.
9634 @param[in] cfg The dictionary containing the configuration data.
9635 @param[in] section The top-level key in the cfg dictionary.
9636 @param[in] key The second-level key whose value is the list of strings.
9637 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
9638 @param[in] header_sources A dictionary of source files for the header.
9639 @param[in] config_dir Optional configuration revision directory.
9640 @return The absolute path to the generated file.
9641 """
9642 print(f"[INFO] Generating {filename}...")
9643 config_dir = config_dir or os.path.join(run_dir, "config")
9644 os.makedirs(config_dir, exist_ok=True)
9645 file_path = os.path.join(config_dir, filename)
9646
9647 lines = [generate_header(run_id, header_sources)]
9648 items = cfg.get(section, {}).get(key, [])
9649 lines.extend(items)
9650
9651 with open(file_path, "w") as f: f.write("\n".join(lines))
9652 print(f"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
9653 return os.path.abspath(file_path)
9654
9655
9656def has_explicit_monitor_whitelist(monitor_cfg: dict) -> bool:
9657 """!
9658 @brief Return True when logging.enabled_functions contains at least one entry.
9659 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
9660 @return Value returned by `has_explicit_monitor_whitelist()`.
9661 """
9662 items = monitor_cfg.get("logging", {}).get("enabled_functions", [])
9663 return bool(items)
9664
9665
9666def resolve_profiling_config(monitor_cfg: dict) -> dict:
9667 """!
9668 @brief Resolve profiling reporting config from monitor.yml.
9669 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
9670 @return Value returned by `resolve_profiling_config()`.
9671 """
9672 profiling_cfg = monitor_cfg.get("profiling", {}) or {}
9673 timestep_cfg = profiling_cfg.get("timestep_output")
9674 final_cfg = profiling_cfg.get("final_summary")
9675
9676 if timestep_cfg is None:
9677 mode = "off"
9678 functions = []
9679 timestep_file = "Profiling_Timestep_Summary.csv"
9680 else:
9681 if not isinstance(timestep_cfg, dict):
9682 raise ValueError("monitor.profiling.timestep_output must be a mapping when provided.")
9683 mode = str(timestep_cfg.get("mode", "off")).lower()
9684 functions = timestep_cfg.get("functions", [])
9685 timestep_file = str(timestep_cfg.get("file", "Profiling_Timestep_Summary.csv"))
9686
9687 if mode not in PROFILING_TIMESTEP_MODES:
9688 raise ValueError("monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
9689 if functions is None:
9690 functions = []
9691 if not isinstance(functions, list):
9692 raise ValueError("monitor.profiling.timestep_output.functions must be a list of function names.")
9693 if not all(isinstance(item, str) and item.strip() for item in functions):
9694 raise ValueError("monitor.profiling.timestep_output.functions entries must be non-empty strings.")
9695 if mode == "selected" and not functions:
9696 raise ValueError("monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
9697 if mode != "selected" and functions:
9698 raise ValueError("monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
9699 if not timestep_file:
9700 raise ValueError("monitor.profiling.timestep_output.file must be a non-empty string.")
9701
9702 if final_cfg is None:
9703 final_enabled = True
9704 elif isinstance(final_cfg, dict):
9705 final_enabled = bool(final_cfg.get("enabled", True))
9706 else:
9707 raise ValueError("monitor.profiling.final_summary must be a mapping when provided.")
9708
9709 return {
9710 "mode": mode,
9711 "functions": functions,
9712 "timestep_file": timestep_file,
9713 "final_summary_enabled": final_enabled,
9714 }
9715
9716
9717DIAGNOSTICS_PETSC_KEYS = {
9718 "info",
9719 "malloc_debug",
9720 "malloc_test",
9721 "malloc_dump",
9722 "malloc_view",
9723 "malloc_view_threshold",
9724 "memory_view",
9725 "log_view",
9726 "log_view_memory",
9727 "log_all",
9728 "log_trace",
9729 "objects_dump",
9730 "options_left",
9731}
9732
9733
9734def _diagnostic_info(value) -> dict:
9735 """!
9736 @brief Validate PETSc info logging configuration.
9737 @param[in] value Boolean or structured PETSc info configuration.
9738 @return Normalized enabled/class-filter mapping.
9739 """
9740 if isinstance(value, bool):
9741 return {"enabled": value, "classes": []}
9742 if value is None:
9743 return {"enabled": False, "classes": []}
9744 if not isinstance(value, dict):
9745 raise ValueError("monitor.diagnostics.petsc.info must be boolean, null, or a mapping.")
9746 unknown = sorted(set(value) - {"enabled", "classes"})
9747 if unknown:
9748 raise ValueError(f"monitor.diagnostics.petsc.info has unsupported key(s): {unknown}.")
9749 enabled = value.get("enabled", True)
9750 classes = value.get("classes", [])
9751 if not isinstance(enabled, bool):
9752 raise ValueError("monitor.diagnostics.petsc.info.enabled must be boolean.")
9753 if not isinstance(classes, list) or not all(
9754 isinstance(item, str) and item.strip() and "," not in item and ":" not in item
9755 for item in classes
9756 ):
9757 raise ValueError(
9758 "monitor.diagnostics.petsc.info.classes must be a list of non-empty PETSc class names."
9759 )
9760 return {"enabled": enabled, "classes": [item.strip() for item in classes]}
9761
9762
9763def _diagnostic_bool_or_path(value, key: str):
9764 """!
9765 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
9766 @param[in] value Candidate value.
9767 @param[in] key Diagnostics key used in error messages.
9768 @return Normalized value.
9769 """
9770 if isinstance(value, bool) or value is None:
9771 return value
9772 if isinstance(value, str) and value.strip():
9773 return value.strip()
9774 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
9775
9776
9777def _diagnostic_bool(value, key: str) -> bool:
9778 """!
9779 @brief Validate a diagnostics boolean value.
9780 @param[in] value Candidate value.
9781 @param[in] key Diagnostics key used in error messages.
9782 @return Boolean value.
9783 """
9784 if isinstance(value, bool):
9785 return value
9786 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean.")
9787
9788
9789def _diagnostic_bool_or_all(value, key: str):
9790 """!
9791 @brief Validate a diagnostics value that can be false, true, or "all".
9792 @param[in] value Candidate value.
9793 @param[in] key Diagnostics key used in error messages.
9794 @return Normalized value.
9795 """
9796 if isinstance(value, bool) or value is None:
9797 return value
9798 if isinstance(value, str) and value.strip().lower() == "all":
9799 return "all"
9800 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
9801
9802
9803def _diagnostic_default_file(run_dir: str, filename: str) -> str:
9804 """!
9805 @brief Return an absolute run-local diagnostics file path.
9806 @param[in] run_dir Run directory.
9807 @param[in] filename Diagnostics filename.
9808 @return Absolute diagnostics path under the run logs directory.
9809 """
9810 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"], filename))
9811
9812
9813def _diagnostic_resolve_path_or_default(value, run_dir: str, default_filename: str):
9814 """!
9815 @brief Resolve true/string diagnostics values to a concrete file path.
9816 @param[in] value Boolean/string diagnostics value.
9817 @param[in] run_dir Run directory.
9818 @param[in] default_filename Default file name when value is true.
9819 @return False, or an absolute/explicit path string.
9820 """
9821 if value is True:
9822 return _diagnostic_default_file(run_dir, default_filename)
9823 if isinstance(value, str):
9824 if os.path.isabs(value) or value.startswith(":"):
9825 return value
9826 return os.path.abspath(os.path.join(run_dir, "logs", value))
9827 return False
9828
9829
9830def resolve_diagnostics_config(monitor_cfg: dict, run_dir: "str | None" = None, stage_label: str = "Solver") -> dict:
9831 """!
9832 @brief Resolve monitor diagnostics config and default run-local log paths.
9833 @param[in] monitor_cfg Parsed monitor.yml mapping.
9834 @param[in] run_dir Optional run directory for default artifact paths.
9835 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
9836 @return Normalized diagnostics config.
9837 """
9838 diagnostics_cfg = (monitor_cfg.get("diagnostics", {}) or {}) if isinstance(monitor_cfg, dict) else {}
9839 if not isinstance(diagnostics_cfg, dict):
9840 raise ValueError("monitor.diagnostics must be a mapping when provided.")
9841
9842 petsc_raw = diagnostics_cfg.get("petsc", {}) or {}
9843 if not isinstance(petsc_raw, dict):
9844 raise ValueError("monitor.diagnostics.petsc must be a mapping when provided.")
9845 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
9846 if unknown:
9847 raise ValueError(f"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
9848
9849 petsc = {
9850 "info": _diagnostic_info(petsc_raw.get("info", False)),
9851 "malloc_debug": _diagnostic_bool(petsc_raw.get("malloc_debug", False), "malloc_debug"),
9852 "malloc_test": _diagnostic_bool(petsc_raw.get("malloc_test", False), "malloc_test"),
9853 "malloc_dump": _diagnostic_bool(petsc_raw.get("malloc_dump", False), "malloc_dump"),
9854 "malloc_view": _diagnostic_bool_or_path(petsc_raw.get("malloc_view", False), "malloc_view"),
9855 "malloc_view_threshold": petsc_raw.get("malloc_view_threshold"),
9856 "memory_view": _diagnostic_bool(petsc_raw.get("memory_view", False), "memory_view"),
9857 "log_view": _diagnostic_bool_or_path(petsc_raw.get("log_view", False), "log_view"),
9858 "log_view_memory": _diagnostic_bool(petsc_raw.get("log_view_memory", False), "log_view_memory"),
9859 "log_all": _diagnostic_bool(petsc_raw.get("log_all", False), "log_all"),
9860 "log_trace": _diagnostic_bool_or_path(petsc_raw.get("log_trace", False), "log_trace"),
9861 "objects_dump": _diagnostic_bool_or_all(petsc_raw.get("objects_dump", False), "objects_dump"),
9862 "options_left": petsc_raw.get("options_left"),
9863 }
9864 if petsc["malloc_view_threshold"] is not None and not isinstance(petsc["malloc_view_threshold"], (int, float)):
9865 raise ValueError("monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
9866 if petsc["options_left"] is not None and not isinstance(petsc["options_left"], bool):
9867 raise ValueError("monitor.diagnostics.petsc.options_left must be boolean or null.")
9868
9869 memory_raw = diagnostics_cfg.get("runtime_memory_log", {}) or {}
9870 if not isinstance(memory_raw, dict):
9871 raise ValueError("monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
9872 memory_unknown = sorted(set(memory_raw.keys()) - {"enabled", "file"})
9873 if memory_unknown:
9874 raise ValueError(f"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
9875 memory_enabled = memory_raw.get("enabled", True)
9876 if not isinstance(memory_enabled, bool):
9877 raise ValueError("monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
9878 memory_file = str(memory_raw.get("file", "Runtime_Memory.log")).strip()
9879 if not memory_file:
9880 raise ValueError("monitor.diagnostics.runtime_memory_log.file must be a non-empty string.")
9881
9882 resolved_petsc = dict(petsc)
9883 artifacts = []
9884 if run_dir:
9885 suffix = "PostProcessor" if stage_label == "PostProcessor" else "Solver"
9886 resolved_petsc["info"] = False
9887 defaults = {
9888 "malloc_view": f"PETSc_MallocView_{suffix}.log",
9889 "log_view": f"PETSc_LogView_{suffix}.log",
9890 "log_trace": f"PETSc_LogTrace_{suffix}.log",
9891 }
9892 if petsc["info"]["enabled"]:
9893 info_path = _diagnostic_default_file(run_dir, f"PETSc_Info_{suffix}.log")
9894 classes = petsc["info"]["classes"]
9895 resolved_petsc["info"] = info_path + (f":{','.join(classes)}" if classes else "")
9896 # PetscInfoSetFile appends the emitting MPI rank (for example `.0`).
9897 artifacts.append(f"{info_path}.*")
9898 for key, default_name in defaults.items():
9899 resolved_value = _diagnostic_resolve_path_or_default(petsc[key], run_dir, default_name)
9900 if key == "log_view" and resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
9901 resolved_value = f":{resolved_value}"
9902 resolved_petsc[key] = resolved_value
9903 if resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
9904 artifacts.append(resolved_value)
9905 elif resolved_value and isinstance(resolved_value, str) and resolved_value.startswith(":"):
9906 artifacts.append(resolved_value[1:])
9907 if memory_enabled:
9908 artifacts.append(
9909 os.path.abspath(os.path.join(
9910 run_dir, CANONICAL_RUN_PATHS["logs"], memory_file
9911 ))
9912 )
9913
9914 return {
9915 "petsc": resolved_petsc,
9916 "runtime_memory_log": {"enabled": memory_enabled, "file": memory_file},
9917 "artifacts": artifacts,
9918 }
9919
9920
9921def build_petsc_diagnostics_args(monitor_cfg: dict, run_dir: str, stage_label: str) -> list:
9922 """!
9923 @brief Build PETSc diagnostics command-line arguments for a run stage.
9924 @param[in] monitor_cfg Parsed monitor.yml mapping.
9925 @param[in] run_dir Run directory used to resolve default diagnostics files.
9926 @param[in] stage_label Stage label for default output names.
9927 @return List of executable arguments.
9928 """
9929 diagnostics = resolve_diagnostics_config(monitor_cfg, run_dir, stage_label)
9930 petsc = diagnostics["petsc"]
9931 args = []
9932 if petsc["info"]:
9933 args.extend(["-info", str(petsc["info"])])
9934 if petsc["malloc_debug"]:
9935 args.append("-malloc_debug")
9936 if petsc["malloc_test"]:
9937 args.append("-malloc_test")
9938 for key, flag in (
9939 ("malloc_dump", "-malloc_dump"),
9940 ("malloc_view", "-malloc_view"),
9941 ("memory_view", "-memory_view"),
9942 ("log_view", "-log_view"),
9943 ("log_trace", "-log_trace"),
9944 ("objects_dump", "-objects_dump"),
9945 ):
9946 value = petsc.get(key)
9947 if value is True:
9948 args.append(flag)
9949 elif value:
9950 args.extend([flag, str(value)])
9951 if petsc["malloc_view_threshold"] is not None:
9952 args.extend(["-malloc_view_threshold", str(petsc["malloc_view_threshold"])])
9953 if petsc["log_view_memory"]:
9954 args.append("-log_view_memory")
9955 if petsc["log_all"]:
9956 args.append("-log_all")
9957 if petsc["options_left"] is not None:
9958 args.extend(["-options_left", "true" if petsc["options_left"] else "false"])
9959 return args
9960
9961
9962def _statistics_subsystem_available(case_cfg: dict, requirement) -> bool:
9963 """!
9964 @brief Report whether the subsystem a statistics field depends on is active.
9965 @param[in] case_cfg Parsed case configuration.
9966 @param[in] requirement Subsystem key from `STATISTICS_ELIGIBLE_FIELDS`, or None.
9967 @return Value returned by `_statistics_subsystem_available()`.
9968 """
9969 if requirement is None:
9970 return True
9971 physics = ((case_cfg or {}).get("models", {}) or {}).get("physics", {}) or {}
9972 if requirement == "particles":
9973 particles = physics.get("particles", {}) or {}
9974 try:
9975 return int(particles.get("count", 0) or 0) > 0
9976 except (TypeError, ValueError):
9977 return False
9978 turbulence = physics.get("turbulence", {}) or {}
9979 les_on = bool((turbulence.get("les", {}) or {}).get("enabled", False))
9980 rans_on = bool((turbulence.get("rans", {}) or {}).get("enabled", False))
9981 if requirement == "les":
9982 return les_on
9983 if requirement == "turbulence":
9984 return les_on or rans_on
9985 return False
9986
9987
9988def normalize_field_statistics_config(monitor_cfg: dict, case_cfg: dict = None) -> dict:
9989 """!
9990 @brief Validate and canonicalize the field-statistics block of monitor.yml.
9991
9992 @details Rejects every condition the field-statistics contract forbids, naming the
9993 offending window so a message points at one entry rather than the block.
9994 Returns a canonical form the flag resolver serializes without further
9995 interpretation, so validation and emission cannot disagree.
9996
9997 @param[in] monitor_cfg Parsed monitor configuration.
9998 @param[in] case_cfg Parsed case configuration, used to check that each field's
9999 subsystem is active. Subsystem checks are skipped when None.
10000 @return Value returned by `normalize_field_statistics_config()`.
10001 """
10002 raw = (monitor_cfg or {}).get("field_statistics")
10003 if raw is None:
10004 return {"enabled": False, "windows": []}
10005 if not isinstance(raw, dict):
10006 raise ValueError("'field_statistics' must be a mapping.")
10007
10008 enabled = raw.get("enabled", False)
10009 if not isinstance(enabled, bool):
10010 raise ValueError("'field_statistics.enabled' must be true or false.")
10011 windows_raw = raw.get("windows", []) or []
10012 if not isinstance(windows_raw, list):
10013 raise ValueError("'field_statistics.windows' must be a list.")
10014 if enabled and not windows_raw:
10015 raise ValueError("'field_statistics.enabled' is true but no window is defined.")
10016
10017 windows = []
10018 seen_names = set()
10019 for index, window in enumerate(windows_raw):
10020 if not isinstance(window, dict):
10021 raise ValueError(f"'field_statistics.windows[{index}]' must be a mapping.")
10022 name = window.get("name")
10023 if not isinstance(name, str) or not name.strip():
10024 raise ValueError(f"'field_statistics.windows[{index}]' needs a non-empty 'name'.")
10025 name = name.strip()
10026 # Window names identify saved state across a restart, so a duplicate would
10027 # make two windows indistinguishable in the checkpoint.
10028 if name in seen_names:
10029 raise ValueError(f"field statistics window '{name}' is defined more than once.")
10030 seen_names.add(name)
10031
10032 start_time = window.get("start_time")
10033 if not isinstance(start_time, (int, float)) or isinstance(start_time, bool):
10034 raise ValueError(f"field statistics window '{name}': 'start_time' must be a number.")
10035 end_time = window.get("end_time")
10036 if end_time is not None:
10037 if not isinstance(end_time, (int, float)) or isinstance(end_time, bool):
10038 raise ValueError(f"field statistics window '{name}': 'end_time' must be a number.")
10039 if float(end_time) <= float(start_time):
10040 raise ValueError(
10041 f"field statistics window '{name}': 'end_time' ({end_time}) must be greater "
10042 f"than 'start_time' ({start_time})."
10043 )
10044
10045 weighting = window.get("weighting")
10046 if weighting not in STATISTICS_WEIGHTING_MODES:
10047 raise ValueError(
10048 f"field statistics window '{name}': 'weighting' must be one of "
10049 f"{list(STATISTICS_WEIGHTING_MODES)} (got {weighting!r})."
10050 )
10051
10052 has_step = "step_cadence" in window and window["step_cadence"] is not None
10053 has_time = "time_cadence" in window and window["time_cadence"] is not None
10054 if has_step == has_time:
10055 raise ValueError(
10056 f"field statistics window '{name}': set exactly one of 'step_cadence' and "
10057 "'time_cadence'."
10058 )
10059 step_cadence = None
10060 time_cadence = None
10061 if has_step:
10062 step_cadence = window["step_cadence"]
10063 if not isinstance(step_cadence, int) or isinstance(step_cadence, bool) or step_cadence <= 0:
10064 raise ValueError(
10065 f"field statistics window '{name}': 'step_cadence' must be a positive integer "
10066 f"(got {step_cadence!r})."
10067 )
10068 else:
10069 time_cadence = window["time_cadence"]
10070 if (not isinstance(time_cadence, (int, float)) or isinstance(time_cadence, bool)
10071 or float(time_cadence) <= 0.0):
10072 raise ValueError(
10073 f"field statistics window '{name}': 'time_cadence' must be a positive number "
10074 f"(got {time_cadence!r})."
10075 )
10076
10077 fields_raw = window.get("fields")
10078 if not isinstance(fields_raw, list) or not fields_raw:
10079 raise ValueError(f"field statistics window '{name}': 'fields' must be a non-empty list.")
10080 fields = []
10081 for field_entry in fields_raw:
10082 if not isinstance(field_entry, dict):
10083 raise ValueError(f"field statistics window '{name}': each 'fields' entry must be a mapping.")
10084 field_name = field_entry.get("field")
10085 if field_name not in STATISTICS_ELIGIBLE_FIELDS:
10086 raise ValueError(
10087 f"field statistics window '{name}': field {field_name!r} cannot be accumulated. "
10088 f"Available fields: {sorted(STATISTICS_ELIGIBLE_FIELDS)}."
10089 )
10090 requirement = STATISTICS_ELIGIBLE_FIELDS[field_name]["requires"]
10091 if case_cfg is not None and not _statistics_subsystem_available(case_cfg, requirement):
10092 raise ValueError(
10093 f"field statistics window '{name}': field '{field_name}' requires the "
10094 f"'{requirement}' subsystem, which is not enabled for this case."
10095 )
10096 if any(existing["field"] == field_name for existing in fields):
10097 raise ValueError(
10098 f"field statistics window '{name}': field '{field_name}' is listed more than once."
10099 )
10100 moments = field_entry.get("moments")
10101 if not isinstance(moments, list) or not moments:
10102 raise ValueError(
10103 f"field statistics window '{name}': field '{field_name}' needs a non-empty "
10104 "'moments' list."
10105 )
10106 unknown = [m for m in moments if m not in STATISTICS_MOMENT_NAMES]
10107 if unknown:
10108 raise ValueError(
10109 f"field statistics window '{name}': field '{field_name}' requests unknown "
10110 f"moments {unknown}. Available moments: {list(STATISTICS_MOMENT_NAMES)}."
10111 )
10112 # The first moment is always kept, because every centered product is
10113 # measured against it.
10114 fields.append({"field": field_name, "moments": ["first"] + (["second"] if "second" in moments else [])})
10115
10116 covariances_raw = window.get("covariances", []) or []
10117 if not isinstance(covariances_raw, list):
10118 raise ValueError(f"field statistics window '{name}': 'covariances' must be a list.")
10119 requested = {entry["field"] for entry in fields}
10120 covariances = []
10121 for pair in covariances_raw:
10122 if not isinstance(pair, list) or len(pair) != 2:
10123 raise ValueError(
10124 f"field statistics window '{name}': each covariance must be a pair of field names."
10125 )
10126 first, second = pair
10127 for member in (first, second):
10128 if member not in STATISTICS_ELIGIBLE_FIELDS:
10129 raise ValueError(
10130 f"field statistics window '{name}': covariance member {member!r} cannot be "
10131 f"accumulated. Available fields: {sorted(STATISTICS_ELIGIBLE_FIELDS)}."
10132 )
10133 if first == second:
10134 raise ValueError(
10135 f"field statistics window '{name}': covariance ['{first}', '{second}'] pairs a "
10136 "field with itself; request that through moments: [second] instead."
10137 )
10138 missing = sorted({first, second} - requested)
10139 if missing:
10140 raise ValueError(
10141 f"field statistics window '{name}': covariance ['{first}', '{second}'] needs "
10142 f"{missing} in 'fields' as well, because a co-moment is centered against their means."
10143 )
10144 # Two three-component fields would need a full nine-component tensor,
10145 # which nothing allocates.
10146 if (STATISTICS_ELIGIBLE_FIELDS[first]["components"] == 3
10147 and STATISTICS_ELIGIBLE_FIELDS[second]["components"] == 3):
10148 raise ValueError(
10149 f"field statistics window '{name}': covariance ['{first}', '{second}'] pairs two "
10150 "vector fields, which is not supported."
10151 )
10152 if sorted((first, second)) in [sorted(existing) for existing in covariances]:
10153 raise ValueError(
10154 f"field statistics window '{name}': covariance ['{first}', '{second}'] is "
10155 "requested more than once."
10156 )
10157 covariances.append([first, second])
10158
10159 windows.append({
10160 "name": name,
10161 "start_time": float(start_time),
10162 "end_time": None if end_time is None else float(end_time),
10163 "weighting": weighting,
10164 "step_cadence": step_cadence,
10165 "time_cadence": None if time_cadence is None else float(time_cadence),
10166 "fields": fields,
10167 "covariances": covariances,
10168 })
10169
10170 return {"enabled": bool(enabled), "windows": windows}
10171
10172
10173def resolve_field_statistics_flags(monitor_cfg: dict, case_cfg: dict = None) -> list:
10174 """!
10175 @brief Serialize field-statistics configuration into control-file option lines.
10176 @details A window list is variable arity, so its option names are constructed from
10177 the index. Each name belongs to a family declared in the ingress audit
10178 manifest.
10179 @param[in] monitor_cfg Parsed monitor configuration.
10180 @param[in] case_cfg Parsed case configuration, for subsystem availability checks.
10181 @return Value returned by `resolve_field_statistics_flags()`.
10182 """
10183 config = normalize_field_statistics_config(monitor_cfg, case_cfg)
10184 if not config["enabled"]:
10185 return []
10186
10187 lines = ["-field_statistics_enabled true",
10188 f"-field_statistics_window_count {len(config['windows'])}"]
10189 for index, window in enumerate(config["windows"]):
10190 prefix = f"-field_statistics_window_{index}"
10191 lines.append(f"{prefix}_name {window['name']}")
10192 lines.append(f"{prefix}_start_time {window['start_time']!r}")
10193 # An absent end time is what makes a window open ended, so the option is
10194 # omitted rather than given a sentinel value.
10195 if window["end_time"] is not None:
10196 lines.append(f"{prefix}_end_time {window['end_time']!r}")
10197 lines.append(f"{prefix}_weighting {window['weighting']}")
10198 if window["step_cadence"] is not None:
10199 lines.append(f"{prefix}_step_cadence {window['step_cadence']}")
10200 else:
10201 lines.append(f"{prefix}_time_cadence {window['time_cadence']!r}")
10202 lines.append(f"{prefix}_field_count {len(window['fields'])}")
10203 for field_index, field_entry in enumerate(window["fields"]):
10204 lines.append(f"{prefix}_field_{field_index}_name {field_entry['field']}")
10205 lines.append(f"{prefix}_field_{field_index}_moments {','.join(field_entry['moments'])}")
10206 lines.append(f"{prefix}_covariance_count {len(window['covariances'])}")
10207 for pair_index, pair in enumerate(window["covariances"]):
10208 lines.append(f"{prefix}_covariance_{pair_index} {pair[0]},{pair[1]}")
10209 return lines
10210
10211
10212def resolve_statistics_console_output_frequency(io_cfg: dict) -> "int | None":
10213 """!
10214 @brief Resolve the statistics console cadence, mirroring the particle one.
10215 @param[in] io_cfg Parsed monitor `io` block.
10216 @return Value returned by `resolve_statistics_console_output_frequency()`.
10217 """
10218 if 'statistics_console_output_frequency' in io_cfg:
10219 return io_cfg['statistics_console_output_frequency']
10220 return io_cfg.get('data_output_frequency')
10221
10222
10223def normalize_solution_monitoring_config(monitor_cfg: dict) -> dict:
10224 """!
10225 @brief Validate and canonicalize physical-solution convergence monitoring.
10226 @param[in] monitor_cfg Parsed monitor.yml mapping.
10227 @return Canonical solution-monitoring configuration.
10228 @throws ValueError when convergence settings are invalid.
10229 """
10230 if not isinstance(monitor_cfg, dict):
10231 raise ValueError("monitor.yml must be a mapping before solution monitoring can be normalized.")
10232 monitoring = monitor_cfg.get("solution_monitoring", {}) or {}
10233 if not isinstance(monitoring, dict):
10234 raise ValueError("solution_monitoring must be a mapping when provided.")
10235 convergence = monitoring.get("convergence", {}) or {}
10236 if not isinstance(convergence, dict):
10237 raise ValueError("solution_monitoring.convergence must be a mapping when provided.")
10238 enabled = convergence.get("enabled", True)
10239 if not isinstance(enabled, bool):
10240 raise ValueError("solution_monitoring.convergence.enabled must be boolean.")
10241 mode = normalize_solution_convergence_mode(convergence.get("mode", "steady_deterministic"))
10242 normalized = {"enabled": enabled, "mode": mode.lower()}
10243 periodic_cfg = convergence.get("periodic_deterministic")
10244 statistical_cfg = convergence.get("statistical_steady")
10245 if mode == "PERIODIC_DETERMINISTIC":
10246 if not isinstance(periodic_cfg, dict):
10247 raise ValueError(
10248 "solution_monitoring.convergence.periodic_deterministic is required for periodic_deterministic mode."
10249 )
10250 period_steps = periodic_cfg.get("period_steps")
10251 if isinstance(period_steps, bool) or not isinstance(period_steps, int) or period_steps <= 0:
10252 raise ValueError(
10253 "solution_monitoring.convergence.periodic_deterministic.period_steps must be a positive integer."
10254 )
10255 normalized["periodic_deterministic"] = {"period_steps": period_steps}
10256 elif periodic_cfg is not None:
10257 raise ValueError(
10258 "solution_monitoring.convergence.periodic_deterministic is only valid for periodic_deterministic mode."
10259 )
10260 if mode == "STATISTICAL_STEADY":
10261 if not isinstance(statistical_cfg, dict):
10262 raise ValueError(
10263 "solution_monitoring.convergence.statistical_steady is required for statistical_steady mode."
10264 )
10265 window_steps = statistical_cfg.get("window_steps")
10266 if isinstance(window_steps, bool) or not isinstance(window_steps, int) or window_steps <= 0:
10267 raise ValueError(
10268 "solution_monitoring.convergence.statistical_steady.window_steps must be a positive integer."
10269 )
10270 normalized["statistical_steady"] = {"window_steps": window_steps}
10271 elif statistical_cfg is not None:
10272 raise ValueError(
10273 "solution_monitoring.convergence.statistical_steady is only valid for statistical_steady mode."
10274 )
10275 return {"convergence": normalized}
10276
10277
10278def resolve_solution_monitoring_flags(monitor_cfg: dict) -> dict:
10279 """!
10280 @brief Translate solution-monitoring YAML into the existing C convergence flags.
10281 @param[in] monitor_cfg Parsed monitor.yml mapping.
10282 @return Mapping of convergence options to explicit runtime values.
10283 """
10284 convergence = normalize_solution_monitoring_config(monitor_cfg)["convergence"]
10285 flags = {
10286 "-solution_convergence_enabled": "true" if convergence["enabled"] else "false",
10287 "-solution_convergence_mode": f'"{normalize_solution_convergence_mode(convergence["mode"])}"',
10288 }
10289 if "periodic_deterministic" in convergence:
10290 flags["-solution_convergence_period_steps"] = convergence["periodic_deterministic"]["period_steps"]
10291 if "statistical_steady" in convergence:
10292 flags["-solution_convergence_window_steps"] = convergence["statistical_steady"]["window_steps"]
10293 return flags
10294
10295
10296def prepare_monitor_files(run_dir: str, run_id: str, monitor_cfg: dict, source_files: dict,
10297 config_dir: str = None) -> dict:
10298 """!
10299 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
10300 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
10301 @param[in] run_id Argument passed to `prepare_monitor_files()`.
10302 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
10303 @param[in] source_files Argument passed to `prepare_monitor_files()`.
10304 @param[in] config_dir Optional configuration revision directory.
10305 @return Value returned by `prepare_monitor_files()`.
10306 """
10307 print("[INFO] Generating monitoring files...")
10308
10309 whitelist_path = None
10310 if has_explicit_monitor_whitelist(monitor_cfg):
10311 whitelist_path = generate_simple_list_file(
10312 run_dir, run_id, monitor_cfg, "logging", "enabled_functions", "whitelist.run", source_files,
10313 config_dir=config_dir,
10314 )
10315 else:
10316 print("[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
10317
10318 profiling_cfg = resolve_profiling_config(monitor_cfg)
10319
10320 profile_path = None
10321 if profiling_cfg["mode"] == "selected":
10322 profile_path = generate_simple_list_file(
10323 run_dir,
10324 run_id,
10325 {"profiling": {"selected_functions": profiling_cfg["functions"]}},
10326 "profiling",
10327 "selected_functions",
10328 "profile.run",
10329 source_files,
10330 config_dir=config_dir,
10331 )
10332 else:
10333 print(f"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
10334
10335 return {
10336 "whitelist": whitelist_path,
10337 "profile": profile_path,
10338 "profiling": profiling_cfg,
10339 }
10340
10341def generate_multi_block_bcs(run_dir: str, run_id: str, case_cfg: dict, source_files: dict,
10342 config_dir: str = None) -> list:
10343 """!
10344 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
10345 and returns a list of their absolute paths.
10346 @details Handles both simple list format (for single-block cases) and a
10347 list-of-lists (for multi-block cases) for boundary conditions.
10348 @param[in] run_dir The path to the main run directory.
10349 @param[in] run_id The unique identifier for the run.
10350 @param[in] case_cfg The parsed case.yml configuration dictionary.
10351 @param[in] source_files A dictionary of source files for the header.
10352 @param[in] config_dir Optional configuration revision directory.
10353 @return A list of absolute paths to the generated BC files.
10354 @throws ValueError if the number of BC definitions does not match the number of blocks.
10355 """
10356 print("[INFO] Generating boundary condition files...")
10357 config_dir = config_dir or os.path.join(run_dir, "config")
10358 os.makedirs(config_dir, exist_ok=True)
10359 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
10360 os.makedirs(profile_dir, exist_ok=True)
10361 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
10362 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
10363 case_path = source_files.get("Case") if source_files else None
10364 profile_grid_dims = None
10365 scales = case_cfg.get('properties', {}).get('scaling', {})
10366 U_ref = _to_float(scales.get('velocity_ref'), "properties.scaling.velocity_ref")
10367 if U_ref == 0.0:
10368 raise ValueError("properties.scaling.velocity_ref must be non-zero for prescribed_flow profile staging.")
10369
10370 if any(bc.get("handler") == "prescribed_flow" for block in prepared_blocks for bc in block):
10371 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
10372
10373 generated_files = []
10374 generated_profile_summaries = []
10375 generated_target_grid = None
10376 field_slice_target_grid = None
10377 for i, block_bcs_list in enumerate(prepared_blocks):
10378 file_name = "bcs.run" if num_blocks == 1 else f"bcs_block{i}.run"
10379 bcs_file_path = os.path.join(config_dir, file_name)
10380 bcs_lines = [generate_header(run_id, source_files)]
10381
10382 for bc in block_bcs_list:
10383 face, bc_type, handler = bc['face'], bc['type'], bc['handler']
10384 params = dict(bc.get('params') or {})
10385 if handler == "prescribed_flow":
10386 source = params.pop("source")
10387 expected_dims = _bc_profile_expected_dims(face, profile_grid_dims[i])
10388 staged_name = f"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
10389 staged_path = os.path.join(profile_dir, staged_name)
10390 if os.path.isfile(staged_path):
10391 with open(staged_path, "r", encoding="utf-8") as stream:
10392 header = [line.strip() for line in stream if line.strip() and not line.lstrip().startswith("#")]
10393 if len(header) < 3 or header[0] != "PICSLICE":
10394 raise ValueError(f"Locked inlet profile is not a PICSLICE file: {staged_path}")
10395 try:
10396 actual_dims = tuple(int(token) for token in header[2].split())
10397 except ValueError as exc:
10398 raise ValueError(f"Locked inlet profile has invalid dimensions: {staged_path}") from exc
10399 if actual_dims != tuple(expected_dims):
10400 raise ValueError(
10401 f"Locked inlet profile {staged_path} has dimensions {actual_dims}; "
10402 f"expected {tuple(expected_dims)}."
10403 )
10404 print(
10405 f"[INFO] Reusing locked prescribed_flow profile for block {i}, "
10406 f"face {face}: {os.path.relpath(staged_path)}"
10407 )
10408 params["source_file"] = os.path.abspath(staged_path)
10409 source = None
10410 elif source["type"] == "file":
10411 source_path = _resolve_case_relative_path(
10412 source["path"], os.path.dirname(os.path.abspath(case_path))
10413 )
10414 elif source["type"] == "generated":
10415 source_path = os.path.join(
10416 profile_dir,
10417 f"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.dimensional.picslice",
10418 )
10419 if os.path.abspath(source_path) == os.path.abspath(staged_path):
10420 raise ValueError(
10421 f"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
10422 )
10423 if source["generator"] == "square_duct_poiseuille":
10424 if generated_target_grid is None:
10425 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
10427 source_path,
10428 expected_dims,
10429 source["params"],
10430 target_grid=generated_target_grid,
10431 target_block=i,
10432 target_face=face,
10433 script=source.get("script"),
10434 case_path=case_path,
10435 )
10436 else:
10437 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
10438 summary.update({"block": i, "face": face})
10439 generated_profile_summaries.append(summary)
10440 elif source["type"] == "field_slice":
10441 source_path = os.path.join(
10442 profile_dir,
10443 f"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.dimensional.picslice",
10444 )
10445 if os.path.abspath(source_path) == os.path.abspath(staged_path):
10446 raise ValueError(
10447 f"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
10448 )
10449 if field_slice_target_grid is None:
10450 field_slice_target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
10452 source_path,
10453 expected_dims,
10454 source,
10455 field_slice_target_grid,
10456 face,
10457 i,
10458 case_path,
10459 )
10460 summary.update({"block": i, "face": face})
10461 generated_profile_summaries.append(summary)
10462 elif source is not None:
10463 raise ValueError(f"Unsupported prescribed_flow source type '{source.get('type')}'.")
10464 if source is not None:
10465 summary = validate_and_nondimensionalize_picslice(source_path, staged_path, U_ref, expected_dims)
10466 print(
10467 f"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
10468 f"{os.path.relpath(staged_path)} dims={summary['dims']}"
10469 )
10470 params["source_file"] = os.path.abspath(staged_path)
10471 params_str = ""
10472 if params:
10473 parts = []
10474 for k, v in params.items():
10475 if isinstance(v, bool):
10476 value_str = "true" if v else "false"
10477 else:
10478 value_str = str(v)
10479 parts.append(f"{k}={value_str}")
10480 params_str = " ".join(parts)
10481 bcs_lines.append(f"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
10482
10483 with open(bcs_file_path, "w") as f: f.write("\n".join(bcs_lines))
10484
10485 print(f"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
10486 generated_files.append(os.path.abspath(bcs_file_path))
10487
10488 if generated_profile_summaries:
10489 info_path = write_profile_info(profile_dir, generated_profile_summaries)
10490 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
10491
10492 return generated_files
10493
10495 """!
10496 @brief Converts Python types to C-style command-line flag values.
10497 @param[in] value The Python object to convert (bool, list, or other).
10498 @return A string representation suitable for a C command-line parser.
10499 """
10500 if isinstance(value, bool):
10501 return "1" if value else "0"
10502 if isinstance(value, list):
10503 return ",".join(map(str, value))
10504 return str(value)
10505
10506def translate_programmatic_grid_settings(grid_settings: dict) -> dict:
10507 """!
10508 @brief Return programmatic-grid settings translated to the C node-count contract.
10509 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
10510 @return Value returned by `translate_programmatic_grid_settings()`.
10511 """
10512 translated = dict(grid_settings)
10513 for dim_key in ("im", "jm", "km"):
10514 if dim_key in translated:
10515 raw_val = translated[dim_key]
10516 if not isinstance(raw_val, int) or raw_val <= 0:
10517 raise ValueError(
10518 f"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
10519 f"(got {raw_val!r})."
10520 )
10521 translated[dim_key] = raw_val + 1
10522 return translated
10523
10524
10525PROGRAMMATIC_GENERATED_IC_GRID_KEYS = (
10526 "im", "jm", "km",
10527 "xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs",
10528 "rxs", "rys", "rzs",
10529)
10530
10531
10533 """!
10534 @brief Validate scalar programmatic grid settings needed by file-generating IC providers.
10535 @param[in] raw_settings programmatic_settings dict from case.yml.
10536 @throws ValueError when required scalar settings are missing or invalid.
10537 """
10538 if not isinstance(raw_settings, dict):
10539 raise ValueError(
10540 "grid.programmatic_settings must be a mapping for a generated initial condition."
10541 )
10542
10543 missing = [key for key in PROGRAMMATIC_GENERATED_IC_GRID_KEYS if key not in raw_settings]
10544 if missing:
10545 raise ValueError(
10546 "grid.programmatic_settings must include "
10547 f"{missing} when grid.mode is 'programmatic_c' and the initial condition requires a grid file."
10548 )
10549
10550 for key in ("im", "jm", "km"):
10551 value = raw_settings[key]
10552 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
10553 raise ValueError(
10554 f"grid.programmatic_settings.{key} must be a positive scalar integer cell count "
10555 "for programmatic_c with a generated initial condition."
10556 )
10557
10558 for key in ("xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs", "rxs", "rys", "rzs"):
10559 value = raw_settings[key]
10560 if isinstance(value, (list, tuple, dict, bool)):
10561 raise ValueError(
10562 f"grid.programmatic_settings.{key} must be a scalar numeric value "
10563 "for a generated initial condition."
10564 )
10565 try:
10566 numeric = float(value)
10567 except (TypeError, ValueError):
10568 raise ValueError(
10569 f"grid.programmatic_settings.{key} must be a scalar numeric value "
10570 "for a generated initial condition."
10571 )
10572 if not math.isfinite(numeric):
10573 raise ValueError(
10574 f"grid.programmatic_settings.{key} must be finite "
10575 "for a generated initial condition."
10576 )
10577 if key in {"rxs", "rys", "rzs"} and numeric <= 0.0:
10578 raise ValueError(
10579 f"grid.programmatic_settings.{key} must be positive "
10580 "for a generated initial condition."
10581 )
10582
10583
10584def generate_picgrid_from_programmatic_settings(raw_settings: dict, dest_path: str, L_ref: float) -> dict:
10585 """!
10586 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
10587 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
10588 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
10589 @param[in] raw_settings programmatic_settings dict from case.yml.
10590 @param[in] dest_path Destination PICGRID file path.
10591 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
10592 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
10593 """
10595 if L_ref == 0.0:
10596 raise ValueError("length_ref must be non-zero for programmatic grid generation.")
10597 IM = int(raw_settings.get("im", 0)) + 1
10598 JM = int(raw_settings.get("jm", 0)) + 1
10599 KM = int(raw_settings.get("km", 0)) + 1
10600 if IM < 2 or JM < 2 or KM < 2:
10601 raise ValueError(
10602 f"programmatic_settings im/jm/km must each be >= 1 "
10603 f"(got im={IM-1}, jm={JM-1}, km={KM-1})."
10604 )
10605 x_min = float(raw_settings.get("xMins", 0.0))
10606 x_max = float(raw_settings.get("xMaxs", 1.0))
10607 y_min = float(raw_settings.get("yMins", 0.0))
10608 y_max = float(raw_settings.get("yMaxs", 1.0))
10609 z_min = float(raw_settings.get("zMins", 0.0))
10610 z_max = float(raw_settings.get("zMaxs", 1.0))
10611 rx = float(raw_settings.get("rxs", 1.0))
10612 ry = float(raw_settings.get("rys", 1.0))
10613 rz = float(raw_settings.get("rzs", 1.0))
10614
10615 def _stretched(idx, N, length, r):
10616 """!
10617 @brief Mirror of ComputeStretchedCoord from src/grid.c.
10618 @param[in] idx Node index along the axis.
10619 @param[in] N Total node count along the axis.
10620 @param[in] length Physical length of the axis.
10621 @param[in] r Geometric stretching ratio.
10622 @return Coordinate offset from the axis minimum.
10623 """
10624 frac = idx / (N - 1.0)
10625 if abs(r - 1.0) < 1.0e-9:
10626 return length * frac
10627 return length * (r ** frac - 1.0) / (r - 1.0)
10628
10629 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
10630 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
10631 with open(dest_path, "w") as fout:
10632 fout.write("PICGRID\n1\n")
10633 fout.write(f"{IM} {JM} {KM}\n")
10634 for k in range(KM):
10635 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
10636 for j in range(JM):
10637 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
10638 for i in range(IM):
10639 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
10640 fout.write(
10641 f"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
10642 f"{format_picgrid_coordinate(z)}\n"
10643 )
10644 total_nodes = IM * JM * KM
10645 return {"nblk": 1, "dims": [(IM, JM, KM)], "total_nodes": total_nodes}
10646
10647
10648GRID_DA_PROCESSOR_KEYS = ("da_processors_x", "da_processors_y", "da_processors_z")
10649
10650
10651def resolve_grid_da_processor_layout(grid_cfg: dict) -> dict:
10652 """!
10653 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
10654 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
10655 @return Value returned by `resolve_grid_da_processor_layout()`.
10656 """
10657 top_level = {}
10658 legacy = {}
10659
10660 for key in GRID_DA_PROCESSOR_KEYS:
10661 value = grid_cfg.get(key)
10662 if isinstance(value, (list, tuple)):
10663 raise ValueError(
10664 f"grid.{key} must be a scalar integer. "
10665 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
10666 )
10667 if value is not None:
10668 if not isinstance(value, int) or value <= 0:
10669 raise ValueError(f"grid.{key} must be a positive integer when provided (got {value}).")
10670 top_level[key] = value
10671
10672 legacy_settings = grid_cfg.get("programmatic_settings")
10673 if isinstance(legacy_settings, dict):
10674 for key in GRID_DA_PROCESSOR_KEYS:
10675 value = legacy_settings.get(key)
10676 if isinstance(value, (list, tuple)):
10677 raise ValueError(
10678 f"grid.programmatic_settings.{key} must be a scalar integer. "
10679 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
10680 )
10681 if value is not None:
10682 if not isinstance(value, int) or value <= 0:
10683 raise ValueError(
10684 f"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
10685 )
10686 legacy[key] = value
10687
10688 resolved = {}
10689 for key in GRID_DA_PROCESSOR_KEYS:
10690 top_value = top_level.get(key)
10691 legacy_value = legacy.get(key)
10692 if top_value is not None and legacy_value is not None and top_value != legacy_value:
10693 raise ValueError(
10694 f"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
10695 "define the processor layout in only one place."
10696 )
10697 if top_value is not None:
10698 resolved[key] = top_value
10699 elif legacy_value is not None:
10700 resolved[key] = legacy_value
10701
10702 return resolved
10703
10704
10705def append_grid_da_processor_layout(control_lines: list, grid_cfg: dict, num_procs: int) -> None:
10706 """!
10707 @brief Append optional global DMDA layout flags for any grid mode.
10708 @param[in] control_lines Argument passed to `append_grid_da_processor_layout()`.
10709 @param[in] grid_cfg Argument passed to `append_grid_da_processor_layout()`.
10710 @param[in] num_procs Argument passed to `append_grid_da_processor_layout()`.
10711 """
10712 layout = resolve_grid_da_processor_layout(grid_cfg)
10713 if not layout:
10714 if num_procs > 1:
10715 print("[INFO] Letting PETSc automatically determine processor layout.")
10716 return
10717
10718 if num_procs == 1:
10719 print("[INFO] Serial run, ignoring da_processors layout.")
10720 return
10721
10722 if all(layout.get(key) is not None for key in GRID_DA_PROCESSOR_KEYS):
10723 total_layout = 1
10724 for key in GRID_DA_PROCESSOR_KEYS:
10725 total_layout *= layout[key]
10726 if total_layout != num_procs:
10727 printable = " x ".join(str(layout[key]) for key in GRID_DA_PROCESSOR_KEYS)
10728 raise ValueError(
10729 "DMDA processor layout mismatch: "
10730 f"grid.da_processors_x/y/z is {printable} (product {total_layout}), "
10731 f"but this run requests {num_procs} MPI processes. "
10732 "Set da_processors_x/y/z to values whose product equals the requested "
10733 "MPI process count, or remove all three settings to let PETSc choose "
10734 "the layout automatically."
10735 )
10736 print(f"[INFO] Applying user-defined processor layout for {num_procs} processes.")
10737 else:
10738 printable = " x ".join(str(layout.get(key, "PETSC_DECIDE")) for key in GRID_DA_PROCESSOR_KEYS)
10739 print(f"[INFO] Applying partial processor layout: {printable}.")
10740
10741 for key in GRID_DA_PROCESSOR_KEYS:
10742 value = layout.get(key)
10743 if value is not None:
10744 control_lines.append(f"-{key} {value}")
10745
10746def normalize_momentum_solver_type(value: str) -> str:
10747 """!
10748 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
10749 @param[in] value Canonical momentum solver string from YAML.
10750 @return Canonical value accepted by -mom_solver_type.
10751 @throws ValueError if the input cannot be mapped.
10752 """
10753 # Only implemented YAML values belong here. Extend only with matching C enum/parser/dispatch support.
10754 if value is None:
10755 raise ValueError("momentum solver type cannot be None")
10756
10757 raw = str(value).strip()
10758 mapped = {
10759 "Explicit RK4": "EXPLICIT_RK",
10760 "Dual Time Picard Jameson RK": "DUALTIME_PICARD_JAMESON_RK",
10761 "Dual Time Picard RK4": "DUALTIME_PICARD_JAMESON_RK",
10762 "Newton Krylov": "newton_krylov",
10763 }.get(raw)
10764 if mapped is None:
10765 raise ValueError(
10766 f"Unknown momentum solver '{value}'. Use one of: "
10767 "'Explicit RK4', 'Dual Time Picard Jameson RK', 'Newton Krylov'."
10768 )
10769 return mapped
10770
10771
10772def validate_newton_krylov_config(cfg: dict) -> dict:
10773 """!
10774 @brief Validate and normalize the structured Newton--Krylov solver block.
10775 @param[in] cfg Structured `momentum_solver.newton_krylov` mapping.
10776 @return Normalized copy containing only supported structured fields.
10777 """
10778 root = "momentum_solver.newton_krylov"
10779 if not isinstance(cfg, dict):
10780 raise ValueError(f"{root} must be a mapping.")
10781
10782 unknown = sorted(set(cfg) - {
10783 "jacobian", "preconditioner", "nonlinear_solver", "linear_solver",
10784 })
10785 if unknown:
10786 raise ValueError(f"{root} has unsupported key(s): {unknown}.")
10787
10788 normalized = {}
10789
10790 def _mapping(parent: dict, key: str, path: str) -> dict:
10791 """!
10792 @brief Read and validate one optional nested Newton mapping.
10793 @param[in] parent Parent mapping.
10794 @param[in] key Nested key to read.
10795 @param[in] path User-facing YAML path for errors.
10796 @return Nested mapping, or an empty mapping when omitted.
10797 """
10798 value = parent.get(key, {})
10799 if value is None or not isinstance(value, dict):
10800 raise ValueError(f"{path} must be a mapping when provided.")
10801 return value
10802
10803 def _method(value, path: str) -> str:
10804 """!
10805 @brief Normalize one nonempty PETSc solver/type token.
10806 @param[in] value YAML token value.
10807 @param[in] path User-facing YAML path for errors.
10808 @return Lowercase PETSc token.
10809 """
10810 if not isinstance(value, str) or not value.strip():
10811 raise ValueError(f"{path} must be a non-empty string.")
10812 return value.strip().lower()
10813
10814 def _tolerance(value, path: str):
10815 """!
10816 @brief Validate one finite nonnegative tolerance.
10817 @param[in] value YAML tolerance value.
10818 @param[in] path User-facing YAML path for errors.
10819 @return Original validated value.
10820 """
10821 if isinstance(value, bool):
10822 raise ValueError(f"{path} must be numeric and nonnegative.")
10823 try:
10824 numeric = float(value)
10825 except (TypeError, ValueError) as exc:
10826 raise ValueError(f"{path} must be numeric and nonnegative.") from exc
10827 if not math.isfinite(numeric) or numeric < 0.0:
10828 raise ValueError(f"{path} must be numeric and nonnegative.")
10829 return value
10830
10831 def _positive_integer(value, path: str):
10832 """!
10833 @brief Validate one positive integer count.
10834 @param[in] value YAML count value.
10835 @param[in] path User-facing YAML path for errors.
10836 @return Original validated integer.
10837 """
10838 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
10839 raise ValueError(f"{path} must be a positive integer.")
10840 return value
10841
10842 jacobian_path = f"{root}.jacobian"
10843 if "jacobian" not in cfg:
10844 normalized["jacobian"] = {
10845 "type": "finite_difference",
10846 "finite_difference": {"mode": "matrix_free"},
10847 }
10848 else:
10849 jacobian = _mapping(cfg, "jacobian", jacobian_path)
10850 unknown = sorted(set(jacobian) - {"type", "finite_difference"})
10851 if unknown:
10852 raise ValueError(f"{jacobian_path} has unsupported key(s): {unknown}.")
10853 if "type" not in jacobian:
10854 raise ValueError(f"{jacobian_path}.type is required when {jacobian_path} is provided.")
10855 jacobian_type = _method(jacobian["type"], f"{jacobian_path}.type")
10856 if jacobian_type == "frozen_momentum_approximation":
10857 raise ValueError(
10858 f"{jacobian_path}.type 'frozen_momentum_approximation' is not implemented."
10859 )
10860 if jacobian_type != "finite_difference":
10861 raise ValueError(
10862 f"{jacobian_path}.type currently supports only 'finite_difference' "
10863 f"(got '{jacobian_type}')."
10864 )
10865 finite_difference_path = f"{jacobian_path}.finite_difference"
10866 if "finite_difference" not in jacobian:
10867 raise ValueError(
10868 f"{finite_difference_path} is required when {jacobian_path}.type is "
10869 "'finite_difference'."
10870 )
10871 finite_difference = _mapping(
10872 jacobian, "finite_difference", finite_difference_path
10873 )
10874 unknown = sorted(set(finite_difference) - {"mode"})
10875 if unknown:
10876 raise ValueError(f"{finite_difference_path} has unsupported key(s): {unknown}.")
10877 if "mode" not in finite_difference:
10878 raise ValueError(f"{finite_difference_path}.mode is required.")
10879 finite_difference_mode = _method(
10880 finite_difference["mode"], f"{finite_difference_path}.mode"
10881 )
10882 if finite_difference_mode == "colored_sparse":
10883 raise ValueError(
10884 f"{finite_difference_path}.mode 'colored_sparse' is not implemented."
10885 )
10886 if finite_difference_mode != "matrix_free":
10887 raise ValueError(
10888 f"{finite_difference_path}.mode currently supports only 'matrix_free' "
10889 f"(got '{finite_difference_mode}')."
10890 )
10891 normalized["jacobian"] = {
10892 "type": jacobian_type,
10893 "finite_difference": {"mode": finite_difference_mode},
10894 }
10895
10896 preconditioner = _mapping(
10897 cfg, "preconditioner", f"{root}.preconditioner"
10898 ) if "preconditioner" in cfg else {}
10899 preconditioner_path = f"{root}.preconditioner"
10900 unknown = sorted(set(preconditioner) - {"model", "structure"})
10901 if unknown:
10902 raise ValueError(f"{preconditioner_path} has unsupported key(s): {unknown}.")
10903 if "preconditioner" in cfg and "model" not in preconditioner:
10904 raise ValueError(
10905 f"{preconditioner_path}.model is required when {preconditioner_path} is provided."
10906 )
10907 model = _method(preconditioner.get("model", "none"), f"{preconditioner_path}.model")
10908 if model not in NEWTON_KRYLOV_PRECONDITIONER_MODELS:
10909 raise ValueError(
10910 f"{preconditioner_path}.model supports only 'none' or 'frozen_momentum_jacobian'."
10911 )
10912 structure = _mapping(
10913 preconditioner, "structure", f"{preconditioner_path}.structure"
10914 ) if "structure" in preconditioner else {}
10915 unknown = sorted(set(structure) - {"type"})
10916 if unknown:
10917 raise ValueError(f"{preconditioner_path}.structure has unsupported key(s): {unknown}.")
10918 structure_type = _method(
10919 structure.get("type", "none"), f"{preconditioner_path}.structure.type"
10920 )
10921 if structure_type not in NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES:
10922 raise ValueError(
10923 f"{preconditioner_path}.structure.type supports only 'none' or 'point_block'."
10924 )
10925 if model == "none" and structure:
10926 raise ValueError(f"{preconditioner_path}.model 'none' does not accept a matrix structure.")
10927 if model == "frozen_momentum_jacobian" and structure_type != "point_block":
10928 raise ValueError(
10929 f"{preconditioner_path}.model 'frozen_momentum_jacobian' requires "
10930 f"{preconditioner_path}.structure.type 'point_block'."
10931 )
10932 normalized["preconditioner"] = {
10933 "model": model,
10934 "structure": {"type": structure_type},
10935 }
10936
10937 nonlinear = _mapping(cfg, "nonlinear_solver", f"{root}.nonlinear_solver")
10938 nonlinear_path = f"{root}.nonlinear_solver"
10939 unknown = sorted(set(nonlinear) - {
10940 "method", "absolute_tolerance", "relative_tolerance", "step_tolerance",
10941 "max_iterations", "line_search", "eisenstat_walker",
10942 })
10943 if unknown:
10944 raise ValueError(f"{nonlinear_path} has unsupported key(s): {unknown}.")
10945 nonlinear_out = {}
10946 if "method" in nonlinear:
10947 nonlinear_out["method"] = _method(nonlinear["method"], f"{nonlinear_path}.method")
10948 for key in ("absolute_tolerance", "relative_tolerance", "step_tolerance"):
10949 if key in nonlinear:
10950 nonlinear_out[key] = _tolerance(nonlinear[key], f"{nonlinear_path}.{key}")
10951 if "max_iterations" in nonlinear:
10952 nonlinear_out["max_iterations"] = _positive_integer(
10953 nonlinear["max_iterations"], f"{nonlinear_path}.max_iterations"
10954 )
10955 if "line_search" in nonlinear:
10956 line_search = _mapping(nonlinear, "line_search", f"{nonlinear_path}.line_search")
10957 unknown = sorted(set(line_search) - {"type"})
10958 if unknown:
10959 raise ValueError(f"{nonlinear_path}.line_search has unsupported key(s): {unknown}.")
10960 nonlinear_out["line_search"] = {}
10961 if "type" in line_search:
10962 nonlinear_out["line_search"]["type"] = _method(
10963 line_search["type"], f"{nonlinear_path}.line_search.type"
10964 )
10965 if "eisenstat_walker" in nonlinear:
10966 ew_path = f"{nonlinear_path}.eisenstat_walker"
10967 ew = _mapping(nonlinear, "eisenstat_walker", ew_path)
10968 ew_keys = {
10969 "enabled", "version", "initial_relative_tolerance",
10970 "maximum_relative_tolerance", "gamma", "exponent",
10971 "safeguard_exponent", "safeguard_threshold",
10972 }
10973 unknown = sorted(set(ew) - ew_keys)
10974 if unknown:
10975 raise ValueError(f"{ew_path} has unsupported key(s): {unknown}.")
10976 enabled = ew.get("enabled", True)
10977 if not isinstance(enabled, bool):
10978 raise ValueError(f"{ew_path}.enabled must be boolean.")
10979 if not enabled and set(ew) - {"enabled"}:
10980 raise ValueError(f"{ew_path} parameters require enabled: true.")
10981 ew_out = {"enabled": enabled}
10982 if "version" in ew:
10983 version = ew["version"]
10984 if isinstance(version, bool) or not isinstance(version, int) or version not in {1, 2, 3, 4}:
10985 raise ValueError(f"{ew_path}.version must be one of 1, 2, 3, or 4.")
10986 ew_out["version"] = version
10987 bounds = {
10988 "initial_relative_tolerance": (0.0, 1.0, False, True),
10989 "maximum_relative_tolerance": (0.0, 1.0, False, True),
10990 "gamma": (0.0, 1.0, False, False),
10991 "exponent": (1.0, 2.0, True, False),
10992 "safeguard_threshold": (0.0, 1.0, True, True),
10993 }
10994 for key, (lower, upper, lower_open, upper_open) in bounds.items():
10995 if key not in ew:
10996 continue
10997 value = _tolerance(ew[key], f"{ew_path}.{key}")
10998 numeric = float(value)
10999 valid_lower = numeric > lower if lower_open else numeric >= lower
11000 valid_upper = numeric < upper if upper_open else numeric <= upper
11001 if not (valid_lower and valid_upper):
11002 brackets = ("(" if lower_open else "[") + f"{lower}, {upper}" + (")" if upper_open else "]")
11003 raise ValueError(f"{ew_path}.{key} must be in {brackets}.")
11004 ew_out[key] = value
11005 if "safeguard_exponent" in ew:
11006 ew_out["safeguard_exponent"] = _tolerance(
11007 ew["safeguard_exponent"], f"{ew_path}.safeguard_exponent"
11008 )
11009 nonlinear_out["eisenstat_walker"] = ew_out
11010 normalized["nonlinear_solver"] = nonlinear_out
11011
11012 linear = _mapping(cfg, "linear_solver", f"{root}.linear_solver")
11013 linear_path = f"{root}.linear_solver"
11014 unknown = sorted(set(linear) - {
11015 "method", "absolute_tolerance", "relative_tolerance", "max_iterations",
11016 "gmres", "preconditioner",
11017 })
11018 if unknown:
11019 raise ValueError(f"{linear_path} has unsupported key(s): {unknown}.")
11020 linear_out = {}
11021 method = "gmres"
11022 if "method" in linear:
11023 method = _method(linear["method"], f"{linear_path}.method")
11024 linear_out["method"] = method
11025 for key in ("absolute_tolerance", "relative_tolerance"):
11026 if key in linear:
11027 linear_out[key] = _tolerance(linear[key], f"{linear_path}.{key}")
11028 if "max_iterations" in linear:
11029 linear_out["max_iterations"] = _positive_integer(
11030 linear["max_iterations"], f"{linear_path}.max_iterations"
11031 )
11032 if "gmres" in linear:
11033 gmres = _mapping(linear, "gmres", f"{linear_path}.gmres")
11034 unknown = sorted(set(gmres) - {"restart"})
11035 if unknown:
11036 raise ValueError(f"{linear_path}.gmres has unsupported key(s): {unknown}.")
11037 linear_out["gmres"] = {}
11038 if "restart" in gmres:
11039 if method not in GMRES_RESTART_METHODS:
11040 raise ValueError(
11041 f"{linear_path}.gmres.restart is valid only when {linear_path}.method "
11042 "is one of 'gmres', 'fgmres', or 'lgmres'."
11043 )
11044 linear_out["gmres"]["restart"] = _positive_integer(
11045 gmres["restart"], f"{linear_path}.gmres.restart"
11046 )
11047 if "preconditioner" in linear:
11048 compatibility_pc = _mapping(linear, "preconditioner", f"{linear_path}.preconditioner")
11049 unknown = sorted(set(compatibility_pc) - {"type"})
11050 if unknown:
11051 raise ValueError(f"{linear_path}.preconditioner has unsupported key(s): {unknown}.")
11052 if "type" in compatibility_pc:
11053 pc_type = _method(compatibility_pc["type"], f"{linear_path}.preconditioner.type")
11054 if pc_type != "none":
11055 raise ValueError(
11056 f"{linear_path}.preconditioner.type is a deprecated compatibility alias "
11057 "and supports only 'none'."
11058 )
11059 if "preconditioner" in cfg and model != "none":
11060 raise ValueError(
11061 f"{linear_path}.preconditioner.type 'none' conflicts with "
11062 f"{preconditioner_path}.model '{model}'."
11063 )
11064 warnings.warn(
11065 f"{linear_path}.preconditioner.type is deprecated; use "
11066 f"{preconditioner_path}.model: none.",
11067 FutureWarning,
11068 stacklevel=3,
11069 )
11070 normalized["linear_solver"] = linear_out
11071 return normalized
11072
11074 """!
11075 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
11076 @param[in] value Human-readable solution-convergence mode selector.
11077 @return Canonical string accepted by `-solution_convergence_mode`.
11078 @throws ValueError if the input cannot be mapped.
11079 """
11080 if value is None:
11081 raise ValueError("solution_convergence.mode cannot be None")
11082
11083 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11084 aliases = {
11085 "steady_deterministic": "STEADY_DETERMINISTIC",
11086 "periodic_deterministic": "PERIODIC_DETERMINISTIC",
11087 "statistical_steady": "STATISTICAL_STEADY",
11088 "transient": "TRANSIENT",
11089 }
11090 mapped = aliases.get(normalized)
11091 if mapped is None:
11092 raise ValueError(
11093 f"Unknown solution_convergence.mode '{value}'. Use one of: "
11094 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
11095 )
11096 return mapped
11097
11098def normalize_field_init_mode(value: str) -> int:
11099 """!
11100 @brief Maps canonical field init mode names to C enum/int codes (-finit).
11101 @param[in] value Canonical field initialization mode.
11102 @return Canonical integer code accepted by -finit.
11103 @throws ValueError if the input cannot be mapped.
11104 """
11105 # Canonical selector map for field initialization modes.
11106 if value is None:
11107 raise ValueError("field initialization mode cannot be None")
11108
11109 mapped = {
11110 "Zero": 0,
11111 "Constant": 1,
11112 "Poiseuille": 2,
11113 }.get(str(value).strip())
11114 if mapped is None:
11115 raise ValueError(
11116 f"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
11117 )
11118 return mapped
11119
11120def normalize_initial_condition_field(value: str) -> "tuple[str, int]":
11121 """!
11122 @brief Normalize a file IC field selector to its staged basename and C enum value.
11123 @param[in] value User-facing Ucat or Ucont selector.
11124 @return Tuple of staged field basename and C enum value.
11125 """
11126 normalized = str(value or "").strip().lower()
11127 if normalized == "ucat":
11128 return "ufield", 0
11129 if normalized == "ucont":
11130 return "vfield", 1
11131 raise ValueError("initial_conditions.field must be 'Ucat' or 'Ucont'.")
11132
11133GENERATED_IC_PROVIDERS = {
11134 "ic_gen": {
11135 "requires_grid": True,
11136 "diagnostic_artifacts": (),
11137 },
11138 "spectral_random_velocity": {
11139 "requires_grid": True,
11140 "requires_periodic_geometric": True,
11141 "requires_fresh_3d": True,
11142 "diagnostic_artifacts": (
11143 ("summary_json", os.path.join(CANONICAL_RUN_PATHS["metrics"], "initial_condition_summary.json")),
11144 ("spectrum_csv", INITIAL_CONDITION_SPECTRUM_RELPATH),
11145 ),
11146 },
11147}
11148
11149
11150def is_generated_ic_provider(resolved_ic: dict) -> bool:
11151 """!
11152 @brief Return whether a resolved IC is backed by a registered file generator.
11153 @param[in] resolved_ic Resolved initial-condition contract.
11154 @return True for a registered generated-file provider.
11155 """
11156 return resolved_ic.get("kind") in GENERATED_IC_PROVIDERS
11157
11158
11159def resolve_fluid_scaling(case_cfg: dict) -> dict:
11160 """!
11161 @brief Resolve the shared physical and nondimensional fluid scaling contract.
11162 @param[in] case_cfg Parsed case configuration.
11163 @return Resolved scaling, viscosity, and Reynolds-number quantities.
11164 """
11165 properties = case_cfg["properties"]
11166 scaling = properties["scaling"]
11167 fluid = properties["fluid"]
11168 length_ref = _to_finite_float(scaling["length_ref"], "properties.scaling.length_ref")
11169 velocity_ref = _to_finite_float(scaling["velocity_ref"], "properties.scaling.velocity_ref")
11170 density = _to_finite_float(fluid["density"], "properties.fluid.density")
11171 dynamic_viscosity = _to_finite_float(fluid["viscosity"], "properties.fluid.viscosity")
11172 if length_ref <= 0.0 or velocity_ref <= 0.0 or density <= 0.0 or dynamic_viscosity < 0.0:
11173 raise ValueError("length_ref, velocity_ref, and density must be positive; viscosity must be non-negative.")
11174 reynolds = density*velocity_ref*length_ref/dynamic_viscosity if dynamic_viscosity else float("inf")
11175 return {
11176 "length_ref": length_ref, "velocity_ref": velocity_ref, "density": density,
11177 "dynamic_viscosity": dynamic_viscosity, "reynolds": reynolds,
11178 "physical_kinematic_viscosity": dynamic_viscosity/density,
11179 "nondimensional_kinematic_viscosity": dynamic_viscosity/(density*velocity_ref*length_ref),
11180 }
11181
11182
11183def resolve_initial_condition_config(ic: dict, prepared_blocks, U_ref: float, provider_context=None) -> dict:
11184 """!
11185 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
11186 @param[in] ic Initial-condition YAML mapping.
11187 @param[in] prepared_blocks Normalized boundary-condition blocks.
11188 @param[in] U_ref Physical reference velocity.
11189 @param[in] provider_context Optional conductor-derived provider context.
11190 @return Normalized launcher initial-condition contract.
11191 """
11192 if not isinstance(ic, dict):
11193 raise ValueError("properties.initial_conditions must be a mapping.")
11194 mode = str(ic.get("mode", "")).strip()
11195
11196 # Backward-compatible legacy spelling.
11197 if mode in LEGACY_FIELD_INIT_SPELLINGS:
11198 finit_code = normalize_field_init_mode(mode)
11199 params = resolve_ic_cli_params(ic, finit_code, prepared_blocks, U_ref)
11200 if finit_code == 1 and params.pop("ic_coordinate_system", 0) == 1:
11201 finit_code = 3
11202 return {"finit": finit_code, "cli_params": params, "kind": "builtin", "label": mode}
11203
11204 normalized_mode = mode.lower().replace("-", "_").replace(" ", "_")
11205 if normalized_mode == "file":
11206 if prepared_blocks and len(prepared_blocks) > 1:
11207 raise ValueError("File-backed initial conditions currently support single-block cases only.")
11208 source_file = ic.get("source_file")
11209 if not isinstance(source_file, str) or not source_file.strip():
11210 raise ValueError("initial_conditions.source_file is required when mode is 'file'.")
11211 field_name, field_code = normalize_initial_condition_field(ic.get("field"))
11212 return {
11213 "finit": 4, "cli_params": {}, "kind": "file", "label": "file",
11214 "source_file": source_file.strip(), "field_name": field_name, "field_code": field_code,
11215 }
11216 if normalized_mode != "generated":
11217 raise ValueError("initial_conditions.mode must be 'generated' or 'file'.")
11218
11219 generator = str(ic.get("generator", "")).strip().lower().replace("-", "_").replace(" ", "_")
11220 params = ic.get("params", {})
11221 if not isinstance(params, dict):
11222 raise ValueError("initial_conditions.params must be a mapping.")
11223 if generator == "ic_gen":
11224 if prepared_blocks and len(prepared_blocks) > 1:
11225 raise ValueError("File-backed initial conditions currently support single-block cases only.")
11226 script = params.get("script")
11227 if script is not None and (not isinstance(script, str) or not script.strip()):
11228 raise ValueError("initial_conditions.params.script must be a non-empty path when provided.")
11229 field_name, field_code = normalize_initial_condition_field(params.get("field"))
11230 config_file = params.get("config_file")
11231 if not isinstance(config_file, str) or not config_file.strip():
11232 raise ValueError("initial_conditions.params.config_file is required for generator 'ic_gen'.")
11233 cli_args = params.get("cli_args", [])
11234 if cli_args is None:
11235 cli_args = []
11236 if not isinstance(cli_args, list):
11237 raise ValueError("initial_conditions.params.cli_args must be a list.")
11238 return {
11239 "finit": 4, "cli_params": {}, "kind": "ic_gen", "label": "ic_gen",
11240 "field_name": field_name, "field_code": field_code,
11241 "config_file": config_file.strip(),
11242 "script": script.strip() if script is not None else None,
11243 "output_file": params.get("output_file"),
11244 "cli_args": cli_args,
11245 }
11246
11247 if generator == "spectral_random_velocity":
11248 if prepared_blocks and len(prepared_blocks) != 1:
11249 raise ValueError("spectral_random_velocity requires exactly one grid block.")
11250 if not prepared_blocks or len(prepared_blocks[0]) != 6 or any(
11251 bc.get("type") != "PERIODIC" or bc.get("handler") != "geometric"
11252 for bc in prepared_blocks[0]
11253 ):
11254 raise ValueError("spectral_random_velocity requires PERIODIC/geometric boundaries on all six faces.")
11255 allowed = {"field", "seed", "random", "spectrum", "projection", "normalization", "remove_mean",
11256 "output_file", "summary_json", "spectrum_csv"}
11257 unknown = sorted(set(params) - allowed)
11258 if unknown:
11259 raise ValueError(f"spectral_random_velocity has unsupported params: {unknown}.")
11260 for path_key in ("output_file", "summary_json", "spectrum_csv"):
11261 value = params.get(path_key)
11262 if value is not None and (not isinstance(value, str) or not value.strip()):
11263 raise ValueError(f"spectral_random_velocity params.{path_key} must be a non-empty path when provided.")
11264 field_name, field_code = normalize_initial_condition_field(params.get("field", "Ucat"))
11265 if field_code != 0:
11266 raise ValueError("spectral_random_velocity supports only params.field: Ucat.")
11267 seed = params.get("seed", 12345)
11268 if isinstance(seed, bool) or not isinstance(seed, int):
11269 raise ValueError("spectral_random_velocity params.seed must be an integer.")
11270 random_cfg = params.get("random", {})
11271 if not isinstance(random_cfg, dict) or set(random_cfg) - {"distribution", "mean"}:
11272 raise ValueError("spectral_random_velocity params.random supports only distribution and mean.")
11273 distribution = str(random_cfg.get("distribution", "gaussian")).lower()
11274 if distribution != "gaussian":
11275 raise ValueError("spectral_random_velocity supports only random.distribution: gaussian.")
11276 mean = random_cfg.get("mean", [0.0, 0.0, 0.0])
11277 if not isinstance(mean, list) or len(mean) != 3:
11278 raise ValueError("spectral_random_velocity random.mean must be a three-component list.")
11279 mean = [_to_finite_float(value, f"initial_conditions.params.random.mean[{index}]")
11280 for index, value in enumerate(mean)]
11281 spectrum = params.get("spectrum", {})
11282 if not isinstance(spectrum, dict):
11283 raise ValueError("spectral_random_velocity params.spectrum must be a mapping.")
11284 spectrum_type = str(spectrum.get("type", "white")).lower()
11285 if spectrum_type == "white":
11286 if set(spectrum) - {"type"}:
11287 raise ValueError("white spectrum supports only type.")
11288 normalized_spectrum = {"type": "white"}
11289 elif spectrum_type == "k4_exponential":
11290 if set(spectrum) - {"type", "k0", "k_cut"} or "k0" not in spectrum or "k_cut" not in spectrum:
11291 raise ValueError("k4_exponential spectrum requires only type, k0, and k_cut.")
11292 normalized_spectrum = {"type": "k4_exponential",
11293 "k0": _to_finite_float(spectrum["k0"], "initial_conditions.params.spectrum.k0"),
11294 "k_cut": _to_finite_float(spectrum["k_cut"], "initial_conditions.params.spectrum.k_cut")}
11295 else:
11296 raise ValueError("spectrum.type must be 'white' or 'k4_exponential'.")
11297 if any(value <= 0 for key, value in normalized_spectrum.items() if key != "type"):
11298 raise ValueError("spectrum k0 and k_cut values must be positive.")
11299 projection = params.get("projection", {"type": "none"})
11300 if not isinstance(projection, dict) or set(projection) - {"type", "operator"}:
11301 raise ValueError("projection supports only type and operator.")
11302 projection_type = str(projection.get("type", "none")).lower()
11303 if projection_type == "none" and "operator" not in projection:
11304 normalized_projection = {"type": "none"}
11305 elif projection_type == "solenoidal" and str(projection.get("operator", "")).lower() in PROJECTION_OPERATORS:
11306 normalized_projection = {"type": "solenoidal", "operator": str(projection["operator"]).lower()}
11307 else:
11308 raise ValueError("projection must be none, or solenoidal with continuum/picurv_discrete operator.")
11309 normalization = params.get("normalization", {"type": "none"})
11310 if not isinstance(normalization, dict) or set(normalization) - {"type", "target"}:
11311 raise ValueError("normalization supports only type and target.")
11312 normalization_type = str(normalization.get("type", "none")).lower()
11313 if normalization_type == "none" and "target" not in normalization:
11314 normalized_normalization = {"type": "none"}
11315 elif normalization_type == "component_rms" and "target" in normalization:
11316 target = _to_finite_float(normalization["target"], "initial_conditions.params.normalization.target")
11317 if target <= 0:
11318 raise ValueError("component_rms normalization.target must be positive.")
11319 normalized_normalization = {"type": "component_rms", "target": target}
11320 else:
11321 raise ValueError("normalization must be none or component_rms with target.")
11322 remove_mean = params.get("remove_mean", True)
11323 if not isinstance(remove_mean, bool):
11324 raise ValueError("spectral_random_velocity remove_mean must be boolean.")
11325 context = dict(provider_context or {})
11326 return {
11327 "finit": 4, "cli_params": {}, "kind": "spectral_random_velocity", "label": "spectral_random_velocity",
11328 "field_name": field_name, "field_code": field_code, "provider_context": context,
11329 "params": {"field": "Ucat", "seed": seed,
11330 "random": {"distribution": distribution, "mean": mean},
11331 "spectrum": normalized_spectrum, "projection": normalized_projection,
11332 "normalization": normalized_normalization, "remove_mean": remove_mean},
11333 "output_file": params.get("output_file"), "summary_json": params.get("summary_json"),
11334 "spectrum_csv": params.get("spectrum_csv"),
11335 }
11336
11337 generator_modes = {
11338 "zero": (0, "Zero"),
11339 "constant": (1, "Constant"),
11340 "streamwise_constant": (3, "Constant"),
11341 "poiseuille": (2, "Poiseuille"),
11342 }
11343 if generator not in generator_modes:
11344 raise ValueError(
11345 "initial_conditions.generator must be one of: zero, constant, "
11346 "streamwise_constant, poiseuille, ic_gen, spectral_random_velocity."
11347 )
11348 finit_code, legacy_mode = generator_modes[generator]
11349 legacy_ic = dict(params)
11350 legacy_ic["mode"] = legacy_mode
11351 cli_params = resolve_ic_cli_params(
11352 legacy_ic,
11353 1 if finit_code == 3 else finit_code,
11354 prepared_blocks,
11355 U_ref,
11356 )
11357 cli_params.pop("ic_coordinate_system", None)
11358 return {"finit": finit_code, "cli_params": cli_params, "kind": "builtin", "label": generator}
11359
11360def validate_petsc_vec_binary(path: str) -> dict:
11361 """!
11362 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
11363 @param[in] path PETSc binary vector path.
11364 @return Summary containing the absolute path and scalar count.
11365 """
11366 import struct
11367 with open(path, "rb") as fin:
11368 header = fin.read(8)
11369 if len(header) != 8:
11370 raise ValueError(f"PETSc Vec file is too short: {path}")
11371 class_id, scalar_count = struct.unpack(">ii", header)
11372 if class_id != 1211214 or scalar_count < 0:
11373 raise ValueError(f"Invalid PETSc Vec header in {path}.")
11374 payload = fin.read()
11375 if len(payload) != scalar_count * 8:
11376 raise ValueError(
11377 f"PETSc Vec payload size mismatch in {path}: expected {scalar_count * 8} bytes, found {len(payload)}."
11378 )
11379 return {"path": os.path.abspath(path), "scalar_count": scalar_count}
11380
11381def run_initial_spectrum_generator(field_path: str, staged_grid: str,
11382 spectrum_path: str, case_dir: str) -> str:
11383 """!
11384 @brief Measure the shell-averaged spectrum of a staged initial condition.
11385
11386 The spectrum has a single implementation in `generators/spectra.gen`, so the
11387 conductor measures the generated field rather than asking the initial-condition
11388 generator to report a spectrum it would have to bin itself.
11389
11390 @param[in] field_path Generated PETSc binary Ucat path.
11391 @param[in] staged_grid Staged canonical PICGRID path.
11392 @param[in] spectrum_path Destination `k,energy` CSV path.
11393 @param[in] case_dir Working directory for the subprocess.
11394 @return Absolute path to the written spectrum CSV.
11395 @throws ValueError when the generator is missing or fails.
11396 """
11397 script = os.path.join(GENERATORS_PATH, "spectra.gen")
11398 if not os.path.isfile(script):
11399 raise ValueError(f"spectra.gen script not found: {script}")
11400 cmd = [sys.executable, script, "shell-spectrum",
11401 "--field-file", field_path, "--source-grid", staged_grid,
11402 "--spectrum-csv", spectrum_path]
11403 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
11404 if result.returncode != 0:
11405 details = (result.stderr or result.stdout or "").strip()
11406 raise ValueError(
11407 f"initial-condition spectrum failed with exit code {result.returncode}. Details:\n{details}"
11408 )
11409 return os.path.abspath(spectrum_path)
11410
11411
11412def run_initial_condition_generator(case_path: str, run_dir: str, resolved_ic: dict) -> str:
11413 """!
11414 @brief Run the repository IC generator.
11415 @param[in] case_path Source case YAML path.
11416 @param[in] run_dir Run or precompute output directory.
11417 @param[in] resolved_ic Normalized external-generator contract.
11418 @return Generated PETSc vector path.
11419 """
11420 case_dir = os.path.dirname(os.path.abspath(case_path))
11421 if resolved_ic["kind"] == "spectral_random_velocity":
11422 script = os.path.join(GENERATORS_PATH, "ic.gen")
11423 output_path = os.path.join(run_dir, "inputs", "initial_condition", "initial_condition.generated.dat")
11424 os.makedirs(os.path.dirname(output_path), exist_ok=True)
11425 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
11426 if not os.path.isfile(staged_grid):
11427 raise ValueError("spectral_random_velocity requires a staged PICGRID at inputs/grid/grid.run.")
11428 summary_path = os.path.join(run_dir, "output", "analysis", "metrics", "initial_condition_summary.json")
11429 spectrum_path = os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)
11430 os.makedirs(os.path.dirname(summary_path), exist_ok=True)
11431 os.makedirs(os.path.dirname(spectrum_path), exist_ok=True)
11432 cmd = [sys.executable, script, "--generator", "spectral_random_velocity",
11433 "--grid", staged_grid, "--output", output_path,
11434 "--params-json", json.dumps(resolved_ic["params"], sort_keys=True),
11435 "--context-json", json.dumps(resolved_ic.get("provider_context", {}), sort_keys=True),
11436 "--summary-json", summary_path]
11437 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
11438 if result.returncode != 0:
11439 details = (result.stderr or result.stdout or "").strip()
11440 raise ValueError(f"spectral_random_velocity failed with exit code {result.returncode}. Details:\n{details}")
11441 validate_petsc_vec_binary(output_path)
11442 run_initial_spectrum_generator(output_path, staged_grid, spectrum_path, case_dir)
11443 return output_path
11444 script = _resolve_generator_script(resolved_ic.get("script"), case_path, "ic.gen")
11445 config_file = resolved_ic["config_file"]
11446 config_file = _resolve_case_relative_path(config_file, case_dir)
11447 if not os.path.isfile(script):
11448 raise ValueError(f"ic.gen script not found: {script}")
11449 if not os.path.isfile(config_file):
11450 raise ValueError(f"initial-condition generator config file not found: {config_file}")
11451 output_path = os.path.join(run_dir, "inputs", "initial_condition", "initial_condition.generated.dat")
11452 os.makedirs(os.path.dirname(output_path), exist_ok=True)
11453 cmd = [sys.executable, script, "-c", config_file, "--field",
11454 "Ucat" if resolved_ic["field_code"] == 0 else "Ucont",
11455 "--output", output_path]
11456 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
11457 if os.path.isfile(staged_grid):
11458 cmd.extend(["--grid", staged_grid])
11459 cmd.extend(str(token) for token in resolved_ic.get("cli_args", []))
11460 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
11461 if result.returncode != 0:
11462 details = (result.stderr or result.stdout or "").strip()
11463 raise ValueError(f"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
11464 validate_petsc_vec_binary(output_path)
11465 return output_path
11466
11467def stage_initial_condition_file(run_dir: str, case_path: str, resolved_ic: dict) -> dict:
11468 """!
11469 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
11470 @param[in] run_dir Run or precompute output directory.
11471 @param[in] case_path Source case YAML path.
11472 @param[in] resolved_ic Normalized file-backed IC contract.
11473 @return Source, staged path, and staging-directory summary.
11474 """
11475 stage_dir = os.path.join(run_dir, "inputs", "initial_condition")
11476 os.makedirs(stage_dir, exist_ok=True)
11477 staged_path = os.path.join(stage_dir, f"{resolved_ic['field_name']}00000_0.dat")
11478 if os.path.isfile(staged_path):
11479 validate_petsc_vec_binary(staged_path)
11480 summary = {
11481 "source": os.path.abspath(staged_path),
11482 "staged": os.path.abspath(staged_path),
11483 "directory": os.path.abspath(stage_dir),
11484 "reused": True,
11485 }
11486 if resolved_ic["kind"] == "spectral_random_velocity":
11487 summary["diagnostics"] = [
11488 os.path.join(run_dir, "output", "analysis", "metrics", "initial_condition_summary.json"),
11489 os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH),
11490 ]
11491 return summary
11492 if is_generated_ic_provider(resolved_ic):
11493 source_path = run_initial_condition_generator(case_path, run_dir, resolved_ic)
11494 else:
11495 source_path = _resolve_case_relative_path(
11496 resolved_ic["source_file"], os.path.dirname(os.path.abspath(case_path))
11497 )
11498 if not os.path.isfile(source_path):
11499 raise ValueError(f"Initial-condition source file not found: {source_path}")
11500 validate_petsc_vec_binary(source_path)
11501 if os.path.abspath(source_path) != os.path.abspath(staged_path):
11502 shutil.copy2(source_path, staged_path)
11503 summary = {"source": os.path.abspath(source_path), "staged": os.path.abspath(staged_path),
11504 "directory": os.path.abspath(stage_dir)}
11505 if resolved_ic["kind"] == "spectral_random_velocity":
11506 summary["diagnostics"] = [
11507 os.path.join(run_dir, "output", "analysis", "metrics", "initial_condition_summary.json"),
11508 os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH),
11509 ]
11510 return summary
11511
11512def normalize_flow_direction_token(value: str) -> int:
11513 """!
11514 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
11515 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
11516 @return Integer 0-5 matching the FlowDirection enum.
11517 @throws ValueError on unknown value.
11518 """
11519 mapped = {
11520 "+Xi": 0, "-Xi": 1,
11521 "+Eta": 2, "-Eta": 3,
11522 "+Zeta": 4, "-Zeta": 5,
11523 }.get(str(value).strip())
11524 if mapped is None:
11525 raise ValueError(
11526 f"Unknown initial_conditions.flow_direction '{value}'. "
11527 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
11528 )
11529 return mapped
11530
11531def _ic_has_inlet(prepared_blocks) -> bool:
11532 """!
11533 @brief Return True if any prepared BC block contains an INLET face.
11534 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
11535 @return True if at least one INLET entry exists across all blocks.
11536 """
11537 if not prepared_blocks:
11538 return False
11539 for block_bcs in prepared_blocks:
11540 for entry in block_bcs:
11541 if entry.get("type") == "INLET":
11542 return True
11543 return False
11544
11545def resolve_ic_cli_params(ic: dict, finit_code: int, prepared_blocks, U_ref: float) -> dict:
11546 """!
11547 @brief Resolve all IC parameters and return a dict of PETSc option values.
11548 @param[in] ic The properties.initial_conditions mapping.
11549 @param[in] finit_code Normalized -finit integer code.
11550 @param[in] prepared_blocks Normalized BC blocks (may be None).
11551 @param[in] U_ref Reference velocity for non-dimensionalization.
11552 @return Dict with keys matching PETSc option names (without leading dash).
11553 @throws KeyError if a required key is absent.
11554 @throws ValueError on invalid combinations or values.
11555 """
11556 result = {}
11557
11558 if finit_code == 0:
11559 return result
11560
11561 if finit_code == 1: # Constant
11562 has_cartesian = any(k in ic for k in ("u_physical", "v_physical", "w_physical"))
11563 has_curvilinear = "velocity_physical" in ic
11564
11565 if has_cartesian and has_curvilinear:
11566 raise ValueError(
11567 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
11568 "velocity_physical (curvilinear) — use one or the other."
11569 )
11570
11571 if has_curvilinear: # curvilinear: scalar speed along flow_direction axis
11572 cs_code = 1
11573 result["ic_coordinate_system"] = cs_code
11574 try:
11575 vel_phys = float(ic["velocity_physical"])
11576 except (TypeError, ValueError) as exc:
11577 raise ValueError(
11578 f"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
11579 "Expected a numeric value."
11580 ) from exc
11581 result["ic_velocity_physical"] = vel_phys / U_ref if U_ref != 0 else 0.0
11582
11583 if "flow_direction" in ic:
11584 result["flow_direction"] = normalize_flow_direction_token(ic["flow_direction"])
11585 elif not _ic_has_inlet(prepared_blocks):
11586 raise ValueError(
11587 "initial_conditions.flow_direction is required for curvilinear Constant IC "
11588 "when no INLET face exists."
11589 )
11590
11591 else: # cartesian: u/v/w_physical → Cart2Contra (default when no velocity_physical)
11592 if "flow_direction" in ic:
11593 raise ValueError(
11594 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
11595 "Use velocity_physical + flow_direction for curvilinear mode."
11596 )
11597 cs_code = 0
11598 result["ic_coordinate_system"] = cs_code
11599 u, v, w = parse_initial_velocity_components(ic, finit_code, require_explicit=True)
11600 scale = 1.0 / U_ref if U_ref != 0 else 0.0
11601 result["ucont_x"] = u * scale
11602 result["ucont_y"] = v * scale
11603 result["ucont_z"] = w * scale
11604
11605 elif finit_code == 2: # Poiseuille
11606 if any(k in ic for k in ("u_physical", "v_physical", "w_physical")):
11607 raise ValueError(
11608 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
11609 )
11610 if "velocity_physical" in ic:
11611 raise ValueError(
11612 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
11613 )
11614 if "peak_velocity_physical" not in ic:
11615 raise KeyError("peak_velocity_physical")
11616 try:
11617 peak = float(ic["peak_velocity_physical"])
11618 except (TypeError, ValueError) as exc:
11619 raise ValueError(
11620 f"Invalid value for initial_conditions.peak_velocity_physical: "
11621 f"{ic['peak_velocity_physical']!r}. Expected a numeric value."
11622 ) from exc
11623 result["ic_velocity_physical"] = peak / U_ref if U_ref != 0 else 0.0
11624
11625 if "flow_direction" in ic:
11626 fd_int = normalize_flow_direction_token(ic["flow_direction"])
11627 # Cross-check: explicit flow_direction must align with INLET face if one exists
11628 if _ic_has_inlet(prepared_blocks):
11629 inlet_axis = infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks)
11630 fd_axis_name = {0: "x", 1: "y", 2: "z"}.get(fd_int // 2, "?")
11631 if inlet_axis and fd_axis_name != inlet_axis:
11632 token = ic["flow_direction"]
11633 raise ValueError(
11634 f"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
11635 f"does not match INLET face axis '{inlet_axis}'."
11636 )
11637 result["flow_direction"] = fd_int
11638 elif not _ic_has_inlet(prepared_blocks):
11639 raise ValueError(
11640 "initial_conditions.flow_direction is required for Poiseuille IC "
11641 "when no INLET face exists."
11642 )
11643
11644 return result
11645
11646def normalize_eulerian_field_source(value: str) -> str:
11647 """!
11648 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
11649 @param[in] value Human-readable or enum-like Eulerian field source.
11650 @return Canonical string accepted by `-euler_field_source`.
11651 @throws ValueError if the input cannot be mapped.
11652 """
11653 if value is None:
11654 raise ValueError("eulerian_field_source cannot be None")
11655
11656 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11657 aliases = {
11658 "solve": "solve",
11659 "load": "load",
11660 "analytical": "analytical",
11661 }
11662 mapped = aliases.get(normalized)
11663 if mapped is None:
11664 raise ValueError(
11665 f"Unknown operation_mode.eulerian_field_source '{value}'. "
11666 "Use one of: 'solve', 'load', 'analytical'."
11667 )
11668 return mapped
11669
11670def normalize_analytical_type(value: str) -> str:
11671 """!
11672 @brief Normalizes the analytical solution selector to the C-side canonical string.
11673 @param[in] value Human-readable analytical solution selector.
11674 @return Canonical string accepted by `-analytical_type`.
11675 @throws ValueError if the input cannot be mapped.
11676 """
11677 # Canonical selector map for analytical solution selectors.
11678 if value is None:
11679 raise ValueError("analytical_type cannot be None")
11680
11681 normalized = str(value).strip().upper().replace("-", "_").replace(" ", "_")
11682 if normalized not in ANALYTICAL_SOLUTION_TYPES:
11683 raise ValueError(
11684 f"Unknown operation_mode.analytical_type '{value}'. "
11685 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
11686 )
11687 return normalized
11688
11689def parse_initial_velocity_components(initial_conditions: dict, finit_code: int, *, require_explicit: bool) -> "tuple[float, float, float]":
11690 """!
11691 @brief Parse initial-condition velocity components with mode-aware defaults.
11692 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
11693 @param[in] finit_code Normalized `-finit` integer code.
11694 @param[in] require_explicit If True, all three component keys must be present.
11695 @return Tuple `(u, v, w)` in physical units.
11696 @throws KeyError if a required component key is missing.
11697 @throws ValueError if a component cannot be converted to float.
11698 """
11699 component_keys = ("u_physical", "v_physical", "w_physical")
11700 components = []
11701 for key in component_keys:
11702 if key not in initial_conditions:
11703 if require_explicit:
11704 raise KeyError(key)
11705 raw_value = 0.0
11706 else:
11707 raw_value = initial_conditions[key]
11708 try:
11709 components.append(float(raw_value))
11710 except (TypeError, ValueError) as exc:
11711 raise ValueError(
11712 f"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
11713 ) from exc
11714 return tuple(components)
11715
11716def infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks: list) -> "str | None":
11717 """!
11718 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
11719 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
11720 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
11721 @throws ValueError if different blocks imply different inlet axes.
11722 """
11723 face_order = ("-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta")
11724 face_axis = {
11725 "-Xi": "x", "+Xi": "x",
11726 "-Eta": "y", "+Eta": "y",
11727 "-Zeta": "z", "+Zeta": "z",
11728 }
11729
11730 inlet_axes = set()
11731 for block_bcs in prepared_blocks:
11732 face_map = {entry["face"]: entry for entry in block_bcs}
11733 for face in face_order:
11734 entry = face_map.get(face)
11735 if entry and entry["type"] == "INLET":
11736 inlet_axes.add(face_axis[face])
11737 break
11738
11739 if not inlet_axes:
11740 return None
11741 if len(inlet_axes) != 1:
11742 raise ValueError(
11743 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
11744 f"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
11745 )
11746 return next(iter(inlet_axes))
11747
11748def normalize_particle_init_mode(value: str) -> int:
11749 """!
11750 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
11751 @param[in] value Canonical particle initialization mode.
11752 @return Canonical integer code accepted by -pinit.
11753 @throws ValueError if the input cannot be mapped.
11754 """
11755 # Canonical selector map for particle initialization modes.
11756 if value is None:
11757 raise ValueError("particle init mode cannot be None")
11758
11759 mapped = {
11760 "Surface": 0,
11761 "Volume": 1,
11762 "PointSource": 2,
11763 "SurfaceEdges": 3,
11764 }.get(str(value).strip())
11765 if mapped is None:
11766 raise ValueError(
11767 f"Unknown particle init_mode '{value}'. Use one of: "
11768 "'Surface', 'Volume', 'PointSource', 'SurfaceEdges'."
11769 )
11770 return mapped
11771
11772def normalize_interpolation_method(value: str) -> int:
11773 """!
11774 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
11775 @param[in] value Canonical interpolation method name.
11776 @return Integer code accepted by -interpolation_method.
11777 @throws ValueError if the input cannot be mapped.
11778 """
11779 if value is None:
11780 raise ValueError("interpolation method cannot be None")
11781
11782 mapped = {
11783 "Trilinear": 0,
11784 "CornerAveraged": 1,
11785 }.get(str(value).strip())
11786 if mapped is None:
11787 raise ValueError(
11788 f"Unknown interpolation_method '{value}'. Use one of: "
11789 "'Trilinear', 'CornerAveraged'."
11790 )
11791 return mapped
11792
11793def normalize_les_model(value) -> int:
11794 """!
11795 @brief Maps LES model selectors to C enum/int codes (-les).
11796 @param[in] value LES selector name or legacy integer/bool value.
11797 @return Integer code accepted by -les.
11798 @throws ValueError if the input cannot be mapped.
11799 """
11800 if isinstance(value, bool):
11801 return 1 if value else 0
11802 if isinstance(value, int):
11803 if value in (0, 1, 2):
11804 return value
11805 raise ValueError("models.physics.turbulence.les must be 0, 1, 2, false/true, or a supported model block.")
11806 if value is None:
11807 raise ValueError("LES model cannot be None")
11808
11809 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11810 mapped = {
11811 "none": 0,
11812 "off": 0,
11813 "disabled": 0,
11814 "no_les": 0,
11815 "constant": 1,
11816 "constant_smagorinsky": 1,
11817 "smagorinsky": 1,
11818 "dynamic": 2,
11819 "dynamic_smagorinsky": 2,
11820 }.get(key)
11821 if mapped is None:
11822 raise ValueError(
11823 f"Unknown LES model '{value}'. Use one of: 'none', "
11824 "'constant_smagorinsky', 'dynamic_smagorinsky'."
11825 )
11826 return mapped
11827
11829 """!
11830 @brief Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
11831 @param[in] value Test-filter selector name or integer code.
11832 @return 0 for the volume-weighted box filter, 1 for the i/k Simpson filter.
11833 @throws ValueError if the input cannot be mapped.
11834 """
11835 if isinstance(value, bool):
11836 raise ValueError("models.physics.turbulence.les.test_filter.kernel must name a filter, not a boolean.")
11837 if isinstance(value, int):
11838 if value in (0, 1):
11839 return value
11840 raise ValueError("models.physics.turbulence.les.test_filter.kernel must be 0, 1, or a supported filter name.")
11841 if value is None:
11842 raise ValueError("LES test_filter.kernel cannot be None")
11843
11844 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11845 mapped = {
11846 "volume_weighted_box": 0,
11847 "box": 0,
11848 "simpson_ik": 1,
11849 }.get(key)
11850 if mapped is None:
11851 raise ValueError(
11852 f"Unknown LES test filter kernel '{value}'. Use one of: "
11853 "'volume_weighted_box', 'simpson_ik'."
11854 )
11855 return mapped
11856
11858 """!
11859 @brief Maps LES grid-filter-width model names to the C -les_filter_width flag.
11860 @param[in] value Filter-width model name or integer code.
11861 @return 0 for cube-root volume, 1 for the geometric mean of the cell extents,
11862 2 for the longest cell extent.
11863 @throws ValueError if the input cannot be mapped.
11864 """
11865 if isinstance(value, bool):
11866 raise ValueError("models.physics.turbulence.les.filter_width must name a model, not a boolean.")
11867 if isinstance(value, int):
11868 if value in (0, 1, 2):
11869 return value
11870 raise ValueError("models.physics.turbulence.les.filter_width must be 0, 1, 2, or a supported model name.")
11871 if value is None:
11872 raise ValueError("LES filter_width cannot be None")
11873
11874 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11875 mapped = {
11876 "cube_root_volume": 0,
11877 "geometric_mean": 1,
11878 "max_edge": 2,
11879 }.get(key)
11880 if mapped is None:
11881 raise ValueError(
11882 f"Unknown LES filter width model '{value}'. Use one of: "
11883 "'cube_root_volume', 'geometric_mean', 'max_edge'."
11884 )
11885 return mapped
11886
11888 """!
11889 @brief Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
11890 @param[in] value Averaging mode name or integer code.
11891 @return 0 for pointwise local averaging, 1 for homogeneous directions, 2 for the
11892 whole block.
11893 @throws ValueError if the input cannot be mapped.
11894 """
11895 if isinstance(value, bool):
11896 raise ValueError("models.physics.turbulence.les.averaging.mode must name a mode, not a boolean.")
11897 if isinstance(value, int):
11898 if value in (0, 1, 2):
11899 return value
11900 raise ValueError("models.physics.turbulence.les.averaging.mode must be 0, 1, 2, or a supported mode name.")
11901 if value is None:
11902 raise ValueError("LES averaging.mode cannot be None")
11903
11904 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11905 mapped = {
11906 "local": 0,
11907 "homogeneous": 1,
11908 "global": 2,
11909 }.get(key)
11910 if mapped is None:
11911 raise ValueError(
11912 f"Unknown LES averaging mode '{value}'. Use one of: "
11913 "'local', 'homogeneous', 'global'."
11914 )
11915 return mapped
11916
11917def normalize_les_clip_mode(value) -> int:
11918 """!
11919 @brief Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
11920 @param[in] value Clipping mode name or integer code.
11921 @return 0 to clamp into [0, max_cs^2], 1 to discard negatives only, 2 to keep the
11922 signed coefficient so backscatter survives.
11923 @throws ValueError if the input cannot be mapped.
11924 """
11925 if isinstance(value, bool):
11926 raise ValueError("models.physics.turbulence.les.clipping.mode must name a mode, not a boolean.")
11927 if isinstance(value, int):
11928 if value in (0, 1, 2):
11929 return value
11930 raise ValueError("models.physics.turbulence.les.clipping.mode must be 0, 1, 2, or a supported mode name.")
11931 if value is None:
11932 raise ValueError("LES clipping.mode cannot be None")
11933
11934 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11935 mapped = {
11936 "clamp": 0,
11937 "clip_negative": 1,
11938 "none": 2,
11939 }.get(key)
11940 if mapped is None:
11941 raise ValueError(
11942 f"Unknown LES clipping mode '{value}'. Use one of: "
11943 "'clamp', 'clip_negative', 'none'."
11944 )
11945 return mapped
11946
11947#: The logical grid directions an LES averaging set may span, in canonical order.
11948LES_AVERAGING_DIRECTION_AXES = ("i", "j", "k")
11949
11951 """!
11952 @brief Maps a list of homogeneous logical directions to the C flag's string form.
11953 @param[in] value List or string naming a subset of the i, j, and k directions.
11954 @return The selected directions as a canonically ordered subset of "ijk".
11955 @throws ValueError if a direction is unknown or repeated.
11956 """
11957 if value is None:
11958 return ""
11959 if isinstance(value, str):
11960 tokens = [token for token in value.strip().lower()]
11961 elif isinstance(value, (list, tuple)):
11962 tokens = [str(token).strip().lower() for token in value]
11963 else:
11964 raise ValueError(
11965 "models.physics.turbulence.les.averaging.directions must be a list such as [i, k]."
11966 )
11967
11968 selected = []
11969 for token in tokens:
11970 if token not in LES_AVERAGING_DIRECTION_AXES:
11971 raise ValueError(
11972 f"Unknown LES averaging direction '{token}'. Use a subset of ['i', 'j', 'k']."
11973 )
11974 if token in selected:
11975 raise ValueError(
11976 f"LES averaging direction '{token}' is repeated; list each direction once."
11977 )
11978 selected.append(token)
11979 return "".join(axis for axis in LES_AVERAGING_DIRECTION_AXES if axis in selected)
11980
11981def normalize_rans_model(value) -> int:
11982 """!
11983 @brief Maps RANS model selectors to the current C -rans switch.
11984 @param[in] value RANS selector name or legacy integer/bool value.
11985 @return Integer code accepted by -rans.
11986 @throws ValueError if the input cannot be mapped.
11987 """
11988 if isinstance(value, bool):
11989 return 1 if value else 0
11990 if isinstance(value, int):
11991 if value in (0, 1):
11992 return value
11993 raise ValueError("models.physics.turbulence.rans must be 0, 1, false/true, or a supported model block.")
11994 if value is None:
11995 raise ValueError("RANS model cannot be None")
11996
11997 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
11998 mapped = {
11999 "none": 0,
12000 "off": 0,
12001 "disabled": 0,
12002 "k_omega": 1,
12003 "komega": 1,
12004 }.get(key)
12005 if mapped is None:
12006 raise ValueError(f"Unknown RANS model '{value}'. Use one of: 'none', 'k_omega'.")
12007 return mapped
12008
12010 """!
12011 @brief Maps wall-function model selectors to the C -wallfunction flag.
12012 @param[in] value Wall-function selector name, or None for the default.
12013 @return Integer code accepted by -wallfunction; 1 log law, 2 Werner-Wengle, 3 Cabot.
12014 @throws ValueError if the input cannot be mapped.
12015 """
12016 if value is None:
12017 return 1
12018 if isinstance(value, bool):
12019 raise ValueError("models.physics.turbulence.wall_function.model must name a model, not a boolean.")
12020 if isinstance(value, int):
12021 if value in (1, 2, 3):
12022 return value
12023 raise ValueError("models.physics.turbulence.wall_function.model must be 1, 2, 3, or a supported model name.")
12024 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
12025 mapped = {
12026 "log_law": 1,
12027 "loglaw": 1,
12028 "werner": 2,
12029 "werner_wengle": 2,
12030 "cabot": 3,
12031 }.get(key)
12032 if mapped is None:
12033 raise ValueError(
12034 "Unknown wall_function model '%s'. Use one of: 'log_law', 'werner', 'cabot'." % value)
12035 return mapped
12036
12037def resolve_enabled_flag(cfg: dict, path: str, default: bool = True) -> bool:
12038 """!
12039 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
12040 @param[in] cfg Mapping that may contain `enabled`.
12041 @param[in] path Human-readable config path for diagnostics.
12042 @param[in] default Value used when `enabled` is omitted.
12043 @return Boolean enabled state.
12044 @throws ValueError if `enabled` is not a YAML boolean.
12045 """
12046 if 'enabled' not in cfg:
12047 return default
12048 if not isinstance(cfg['enabled'], bool):
12049 raise ValueError(f"{path}.enabled must be true or false.")
12050 return cfg['enabled']
12051
12052def append_les_parameter_flags(les_cfg: dict, control_lines: list):
12053 """!
12054 @brief Appends the LES closure parameter flags from a structured les block.
12055 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
12056 @param[out] control_lines A list of strings to which C-flags will be appended.
12057 @throws ValueError if a selector name or nested block shape is unsupported.
12058 """
12059 if 'constant_cs' in les_cfg:
12060 control_lines.append(f"-les_constant_cs {format_flag_value(les_cfg['constant_cs'])}")
12061 if 'dynamic_frequency' in les_cfg:
12062 control_lines.append(f"-les_dynamic_frequency {format_flag_value(les_cfg['dynamic_frequency'])}")
12063 if 'filter_width' in les_cfg:
12064 control_lines.append(f"-les_filter_width {normalize_les_filter_width(les_cfg['filter_width'])}")
12065
12066 # A bare string is accepted as shorthand for naming only the kernel, matching the
12067 # way the les block itself accepts either a scalar or a mapping.
12068 test_filter = les_cfg.get('test_filter')
12069 if test_filter is not None:
12070 if not isinstance(test_filter, dict):
12071 test_filter = {'kernel': test_filter}
12072 if 'kernel' in test_filter:
12073 control_lines.append(
12074 f"-les_test_filter_kernel {normalize_les_test_filter(test_filter['kernel'])}")
12075 if 'width_ratio' in test_filter:
12076 control_lines.append(
12077 f"-les_test_filter_width_ratio {format_flag_value(test_filter['width_ratio'])}")
12078
12079 averaging = les_cfg.get('averaging')
12080 if averaging is not None:
12081 if not isinstance(averaging, dict):
12082 averaging = {'mode': averaging}
12083 if 'mode' in averaging:
12084 control_lines.append(
12085 f"-les_averaging_mode {normalize_les_averaging_mode(averaging['mode'])}")
12086 if 'directions' in averaging:
12087 directions = normalize_les_averaging_directions(averaging['directions'])
12088 if directions:
12089 control_lines.append(f"-les_averaging_directions {directions}")
12090
12091 clipping = les_cfg.get('clipping')
12092 if clipping is not None:
12093 if not isinstance(clipping, dict):
12094 clipping = {'mode': clipping}
12095 if 'mode' in clipping:
12096 control_lines.append(f"-les_clip_mode {normalize_les_clip_mode(clipping['mode'])}")
12097 if 'max_cs' in clipping:
12098 control_lines.append(f"-les_clip_max_cs {format_flag_value(clipping['max_cs'])}")
12099 if 'min_viscosity_ratio' in clipping:
12100 control_lines.append(
12101 f"-les_min_viscosity_ratio {format_flag_value(clipping['min_viscosity_ratio'])}")
12102
12103 gradient_model = les_cfg.get('gradient_model')
12104 if gradient_model is not None:
12105 if not isinstance(gradient_model, dict):
12106 gradient_model = {'enabled': gradient_model}
12107 enabled = resolve_enabled_flag(gradient_model,
12108 "models.physics.turbulence.les.gradient_model")
12109 control_lines.append(f"-les_gradient_model {1 if enabled else 0}")
12110
12111 diagnostics = les_cfg.get('diagnostics')
12112 if diagnostics is not None:
12113 if not isinstance(diagnostics, dict):
12114 diagnostics = {'enabled': diagnostics}
12115 enabled = resolve_enabled_flag(diagnostics, "models.physics.turbulence.les.diagnostics")
12116 control_lines.append(f"-les_diagnostics {'true' if enabled else 'false'}")
12117 if 'cadence' in diagnostics:
12118 control_lines.append(
12119 f"-les_diagnostics_cadence {format_flag_value(diagnostics['cadence'])}")
12120 if 'yoshizawa_ci' in diagnostics:
12121 control_lines.append(
12122 f"-les_yoshizawa_ci {format_flag_value(diagnostics['yoshizawa_ci'])}")
12123
12124def append_turbulence_flags(models: dict, control_lines: list):
12125 """!
12126 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
12127 @param[in] models Parsed case.yml `models` mapping.
12128 @param[out] control_lines A list of strings to which C-flags will be appended.
12129 """
12130 turbulence_cfg = models.get('physics', {}).get('turbulence', {})
12131 if not turbulence_cfg:
12132 return
12133 if not isinstance(turbulence_cfg, dict):
12134 raise ValueError("models.physics.turbulence must be a mapping.")
12135
12136 les_cfg = turbulence_cfg.get('les')
12137 rans_cfg = turbulence_cfg.get('rans')
12138 wall_cfg = turbulence_cfg.get('wall_function')
12139 les_code = None
12140 rans_code = None
12141
12142 if isinstance(les_cfg, dict):
12143 enabled = resolve_enabled_flag(les_cfg, "models.physics.turbulence.les")
12144 model_value = les_cfg.get('model', 'constant_smagorinsky')
12145 les_code = normalize_les_model(model_value) if enabled else 0
12146 control_lines.append(f"-les {les_code}")
12147 append_les_parameter_flags(les_cfg, control_lines)
12148 elif les_cfg is not None:
12149 les_code = normalize_les_model(les_cfg)
12150 control_lines.append(f"-les {les_code}")
12151
12152 if isinstance(rans_cfg, dict):
12153 enabled = resolve_enabled_flag(rans_cfg, "models.physics.turbulence.rans")
12154 model_value = rans_cfg.get('model', 'k_omega')
12155 rans_code = normalize_rans_model(model_value) if enabled else 0
12156 control_lines.append(f"-rans {rans_code}")
12157 elif rans_cfg is not None:
12158 rans_code = normalize_rans_model(rans_cfg)
12159 control_lines.append(f"-rans {rans_code}")
12160
12161 if les_code and rans_code:
12162 raise ValueError("models.physics.turbulence cannot enable both LES and RANS in the same case.")
12163
12164 if isinstance(wall_cfg, dict):
12165 enabled = resolve_enabled_flag(wall_cfg, "models.physics.turbulence.wall_function")
12166 # The flag carries the model, with zero meaning disabled, so a selector change
12167 # reaches the runtime instead of being validated and dropped.
12168 wall_model = normalize_wall_function_model(wall_cfg.get('model'))
12169 control_lines.append(f"-wallfunction {wall_model if enabled else 0}")
12170 if 'roughness_height' in wall_cfg:
12171 control_lines.append(f"-wall_roughness {format_flag_value(wall_cfg['roughness_height'])}")
12172 elif wall_cfg is not None:
12173 control_lines.append(f"-wallfunction {format_flag_value(wall_cfg)}")
12174
12175def control_value(value, context: str):
12176 """!
12177 @brief Guard a value that is written verbatim into the generated control file.
12178
12179 @details PETSc reads the control file line by line, so a value containing a newline
12180 writes additional option lines - which defeats every key-based check by
12181 smuggling a whole new flag. Rejecting the value is the only safe handling;
12182 there is no quoting that makes a multi-line option meaningful.
12183 @param[in] value Configured value destined for a control line.
12184 @param[in] context YAML path, for the error message.
12185 @return The value unchanged when it is safe to emit.
12186 @throws SystemExit when the value would inject an option line.
12187 """
12188 text = str(value)
12189 if any(character in text for character in ("\n", "\r")):
12190 print(
12191 f"[FATAL] {context} contains a newline. Values written to the generated control "
12192 f"file must occupy a single line; a multi-line value would inject additional "
12193 f"PETSc options.",
12194 file=sys.stderr,
12195 )
12196 sys.exit(1)
12197 return value
12198
12199
12200def append_passthrough_flags(control_lines: list, options: dict):
12201 """!
12202 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
12203 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
12204 is skipped. All other values are emitted as "<flag> <value>".
12205 @param[out] control_lines The destination list of control-file lines.
12206 @param[in] options Mapping of raw CLI flags to values.
12207 """
12208 if not options:
12209 return
12210 for flag, value in options.items():
12211 if isinstance(flag, str) and flag.strip() in RESERVED_DIRECTORY_FLAGS:
12212 # Defense in depth: validation rejects these, but the emitter must never
12213 # write a run-directory flag from an unvalidated passthrough surface.
12214 raise ValueError(
12215 f"Refusing to emit reserved directory flag '{flag.strip()}' from passthrough "
12216 "options; run directories are fixed by the workspace contract."
12217 )
12218 if isinstance(value, bool):
12219 if value:
12220 control_lines.append(str(flag))
12221 continue
12222 control_lines.append(
12223 f"{flag} {control_value(format_flag_value(value), f'passthrough option {flag}')}"
12224 )
12225
12226
12227SOLVER_MONITORING_POISSON_FLAG_MAP = {
12228 "pic_true_residual": "-ps_ksp_pic_monitor_true_residual",
12229 "true_residual": "-ps_ksp_monitor_true_residual",
12230 "converged_reason": "-ps_ksp_converged_reason",
12231 "view": "-ps_ksp_view",
12232}
12233
12234SOLVER_MONITORING_MOMENTUM_FLAG_MAP = {
12235 "newton_krylov_history": "-mom_nk_pic_monitor",
12236 "snes_monitor": "-mom_nk_snes_monitor",
12237 "snes_converged_reason": "-mom_nk_snes_converged_reason",
12238 "ksp_monitor": "-mom_nk_ksp_monitor",
12239 "ksp_converged_reason": "-mom_nk_ksp_converged_reason",
12240}
12241
12242
12243def resolve_solver_monitoring_flags(monitor_cfg: dict) -> dict:
12244 """!
12245 @brief Resolve human-readable solver monitoring YAML to raw control flags.
12246 @param[in] monitor_cfg Parsed monitor.yml mapping.
12247 @return Mapping of raw C/PETSc flags to values.
12248 """
12249 solver_mon_cfg = monitor_cfg.get("solver_monitoring", {}) if isinstance(monitor_cfg, dict) else {}
12250 if solver_mon_cfg is None:
12251 return {}
12252 if not isinstance(solver_mon_cfg, dict):
12253 raise ValueError("monitor.solver_monitoring must be a mapping when provided.")
12254
12255 flags = {}
12256
12257 momentum_cfg = solver_mon_cfg.get("momentum", {})
12258 if momentum_cfg is None:
12259 momentum_cfg = {}
12260 if not isinstance(momentum_cfg, dict):
12261 raise ValueError("monitor.solver_monitoring.momentum must be a mapping when provided.")
12262 unknown_momentum = sorted(set(momentum_cfg.keys()) - set(SOLVER_MONITORING_MOMENTUM_FLAG_MAP.keys()))
12263 if unknown_momentum:
12264 raise ValueError(f"monitor.solver_monitoring.momentum has unsupported key(s): {unknown_momentum}.")
12265 for key, flag in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.items():
12266 if key in momentum_cfg:
12267 value = momentum_cfg[key]
12268 if not isinstance(value, bool):
12269 raise ValueError(f"monitor.solver_monitoring.momentum.{key} must be boolean.")
12270 flags[flag] = value
12271
12272 poisson_cfg = solver_mon_cfg.get("poisson", {})
12273 if poisson_cfg is None:
12274 poisson_cfg = {}
12275 if not isinstance(poisson_cfg, dict):
12276 raise ValueError("monitor.solver_monitoring.poisson must be a mapping when provided.")
12277 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
12278 if unknown_poisson:
12279 raise ValueError(f"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
12280 for key, flag in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
12281 if key in poisson_cfg:
12282 value = poisson_cfg[key]
12283 if not isinstance(value, bool):
12284 raise ValueError(f"monitor.solver_monitoring.poisson.{key} must be boolean.")
12285 flags[flag] = value
12286
12287 passthrough = solver_mon_cfg.get("petsc_passthrough_options", {})
12288 if passthrough is None:
12289 passthrough = {}
12290 if not isinstance(passthrough, dict):
12291 raise ValueError("monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
12292 flags.update(passthrough)
12293
12294 legacy_raw = {
12295 key: value
12296 for key, value in solver_mon_cfg.items()
12297 if isinstance(key, str) and key.startswith("-")
12298 }
12299 flags.update(legacy_raw)
12300
12301 unknown_top = sorted(
12302 key
12303 for key in solver_mon_cfg.keys()
12304 if key not in {"momentum", "poisson", "petsc_passthrough_options"} and not (isinstance(key, str) and key.startswith("-"))
12305 )
12306 if unknown_top:
12307 raise ValueError(
12308 "monitor.solver_monitoring has unsupported key(s): "
12309 f"{unknown_top}. Use 'momentum'/'poisson' for structured monitors or "
12310 "'petsc_passthrough_options' for raw PETSc flags."
12311 )
12312
12313 return flags
12314
12315
12316def resolve_particle_console_output_frequency(io_cfg: dict) -> "int | None":
12317 """!
12318 @brief Return the effective particle-console snapshot cadence from monitor.yml.
12319 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
12320 @return Value returned by `resolve_particle_console_output_frequency()`.
12321 """
12322 if 'particle_console_output_frequency' in io_cfg:
12323 return io_cfg['particle_console_output_frequency']
12324 return io_cfg.get('data_output_frequency')
12325
12326def parse_and_add_model_flags(case_cfg: dict, control_lines: list):
12327 """!
12328 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
12329 @param[in] case_cfg The parsed case.yml configuration dictionary.
12330 @param[out] control_lines A list of strings to which C-flags will be appended.
12331 """
12332 models = case_cfg.get('models', {})
12333 FLAG_MAP = {
12334 'domain': {'blocks': '-nblk'},
12335 'physics.fsi': {'immersed': '-imm', 'moving_fsi': '-fsi'},
12336 'physics.particles': {'count': '-numParticles'},
12337 }
12338 for section_path, flags in FLAG_MAP.items():
12339 current_level = models
12340 try:
12341 for key in section_path.split('.'): current_level = current_level[key]
12342 for yaml_key, flag in flags.items():
12343 if yaml_key in current_level:
12344 control_lines.append(f"{flag} {format_flag_value(current_level[yaml_key])}")
12345 except KeyError: continue
12346
12347 append_turbulence_flags(models, control_lines)
12348
12349 if models.get('physics', {}).get('dimensionality') == '2D':
12350 control_lines.append("-TwoD 1")
12351
12352 particles_cfg = models.get('physics', {}).get('particles', {})
12353 p_init_mode_str = particles_cfg.get('init_mode', 'Surface')
12354 pinit_code = normalize_particle_init_mode(p_init_mode_str)
12355 control_lines.append(f"-pinit {pinit_code}")
12356 print(f" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
12357
12358 if pinit_code == 2:
12359 point_cfg = particles_cfg.get('point_source', {})
12360 if not isinstance(point_cfg, dict):
12361 raise ValueError("models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
12362 try:
12363 psrc_x = float(point_cfg['x'])
12364 psrc_y = float(point_cfg['y'])
12365 psrc_z = float(point_cfg['z'])
12366 except (KeyError, TypeError, ValueError):
12367 raise ValueError("PointSource init_mode requires numeric point_source.{x,y,z} values.")
12368 control_lines.append(f"-psrc_x {psrc_x}")
12369 control_lines.append(f"-psrc_y {psrc_y}")
12370 control_lines.append(f"-psrc_z {psrc_z}")
12371 print(f" - Particle Point Source: ({psrc_x}, {psrc_y}, {psrc_z})")
12372
12373 p_restart_mode = particles_cfg.get('restart_mode')
12374 if p_restart_mode:
12375 p_restart_mode_normalized = str(p_restart_mode).lower()
12376 if p_restart_mode_normalized not in PARTICLE_RESTART_MODES:
12377 raise ValueError(f"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
12378 control_lines.append(f"-particle_restart_mode \"{p_restart_mode}\"")
12379
12380# PETSc KSP types whose iteration adapts to the input vector. Any of these used as
12381# the multigrid coarse solve (level_0) makes the MG preconditioner a nonlinear
12382# operator; see docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.
12383KRYLOV_KSP_TYPES = {
12384 "gmres", "fgmres", "lgmres", "dgmres", "pgmres", "gcr",
12385 "cg", "cgne", "cgs", "bcgs", "ibcgs", "fbcgs", "fbcgsr", "bcgsl",
12386 "tfqmr", "tcqmr", "minres", "symmlq", "cr", "lsqr", "pipecg", "pipefgmres",
12387}
12388
12389def parse_solver_config(solver_cfg: dict) -> dict:
12390 """!
12391 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
12392 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
12393 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
12394 """
12395 flags = {}
12396 if 'operation_mode' in solver_cfg and isinstance(solver_cfg['operation_mode'], dict):
12397 op_mode = solver_cfg['operation_mode']
12398 if 'eulerian_field_source' in op_mode:
12399 normalized_source = normalize_eulerian_field_source(op_mode.get('eulerian_field_source'))
12400 flags['-euler_field_source'] = f"\"{normalized_source}\""
12401 if 'analytical_type' in op_mode and op_mode.get('analytical_type') is not None:
12402 normalized_analytical_type = normalize_analytical_type(op_mode.get('analytical_type'))
12403 flags['-analytical_type'] = f"\"{normalized_analytical_type}\""
12404 if normalized_analytical_type == "UNIFORM_FLOW":
12405 uniform_flow_cfg = op_mode.get('uniform_flow', {})
12406 if not isinstance(uniform_flow_cfg, dict):
12407 raise ValueError("operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
12408 try:
12409 flags['-analytical_uniform_u'] = float(uniform_flow_cfg['u'])
12410 flags['-analytical_uniform_v'] = float(uniform_flow_cfg['v'])
12411 flags['-analytical_uniform_w'] = float(uniform_flow_cfg['w'])
12412 except KeyError as exc:
12413 raise ValueError(f"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.") from exc
12414 except (TypeError, ValueError) as exc:
12415 raise ValueError("operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.") from exc
12416
12417 verification_cfg = solver_cfg.get('verification', {})
12418 if verification_cfg:
12419 if not isinstance(verification_cfg, dict):
12420 raise ValueError("verification must be a mapping when provided.")
12421 sources_cfg = verification_cfg.get('sources', {})
12422 if not isinstance(sources_cfg, dict):
12423 raise ValueError("verification.sources must be a mapping when provided.")
12424 diff_cfg = sources_cfg.get('diffusivity')
12425 if diff_cfg is not None:
12426 if not isinstance(diff_cfg, dict):
12427 raise ValueError("verification.sources.diffusivity must be a mapping.")
12428 try:
12429 flags['-verification_diffusivity_mode'] = f"\"{str(diff_cfg['mode']).strip().lower()}\""
12430 flags['-verification_diffusivity_profile'] = f"\"{str(diff_cfg['profile']).strip().upper()}\""
12431 flags['-verification_diffusivity_gamma0'] = float(diff_cfg['gamma0'])
12432 flags['-verification_diffusivity_slope_x'] = float(diff_cfg['slope_x'])
12433 except KeyError as exc:
12434 raise ValueError(f"verification.sources.diffusivity.{exc.args[0]} is required.") from exc
12435 except (TypeError, ValueError) as exc:
12436 raise ValueError("verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.") from exc
12437
12438 scalar_cfg = sources_cfg.get('scalar')
12439 if scalar_cfg is not None:
12440 if not isinstance(scalar_cfg, dict):
12441 raise ValueError("verification.sources.scalar must be a mapping.")
12442 try:
12443 flags['-verification_scalar_mode'] = f"\"{str(scalar_cfg['mode']).strip().lower()}\""
12444 flags['-verification_scalar_profile'] = f"\"{str(scalar_cfg['profile']).strip().upper()}\""
12445 except KeyError as exc:
12446 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
12447
12448 scalar_numeric_keys = {
12449 'CONSTANT': ('value',),
12450 'LINEAR_X': ('phi0', 'slope_x'),
12451 'SIN_PRODUCT': ('amplitude', 'kx', 'ky', 'kz'),
12452 }
12453 profile = str(scalar_cfg.get('profile', '')).strip().upper()
12454 for key in scalar_numeric_keys.get(profile, ()):
12455 try:
12456 flags[f'-verification_scalar_{key}'] = float(scalar_cfg[key])
12457 except KeyError as exc:
12458 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
12459 except (TypeError, ValueError) as exc:
12460 raise ValueError(f"verification.sources.scalar.{key} must be numeric.") from exc
12461
12462 transport_cfg = solver_cfg.get('scalar_transport', {})
12463 if transport_cfg:
12464 if not isinstance(transport_cfg, dict):
12465 raise ValueError("scalar_transport must be a mapping when provided.")
12466 transport_map = {
12467 'schmidt_number': '-schmidt_number',
12468 'turbulent_schmidt_number': '-turb_schmidt_number',
12469 }
12470 unknown_transport_keys = sorted(set(transport_cfg.keys()) - set(transport_map.keys()))
12471 if unknown_transport_keys:
12472 raise ValueError(
12473 f"scalar_transport has unsupported key(s): {unknown_transport_keys}. "
12474 "Use 'schmidt_number' or 'turbulent_schmidt_number'."
12475 )
12476 for key, flag in transport_map.items():
12477 if key in transport_cfg:
12478 try:
12479 value = float(transport_cfg[key])
12480 except (TypeError, ValueError) as exc:
12481 raise ValueError(f"scalar_transport.{key} must be numeric.") from exc
12482 if value <= 0.0:
12483 raise ValueError(f"scalar_transport.{key} must be positive.")
12484 flags[flag] = value
12485
12486 selected_solver = None
12487 if 'strategy' in solver_cfg:
12488 s = solver_cfg['strategy']
12489 if 'central_diff' in s:
12490 flags['-central'] = format_flag_value(s['central_diff'])
12491 # Preferred selector.
12492 if 'momentum_solver' in s:
12493 selected_solver = normalize_momentum_solver_type(s['momentum_solver'])
12494 elif 'implicit' in s:
12495 raise ValueError("Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
12496
12497 def _warn_inactive_absolute_tol(cfg: dict, where: str):
12498 """!
12499 @brief Warn when absolute_tol is set but cannot affect convergence.
12500
12501 absolute_tol bounds the velocity update. Since |dU| ~ dtau*|R|, bounding it
12502 absolutely is a disguised, step-size-dependent residual bound, so it takes no
12503 part once a residual tolerance is active -- which is now the default. Setting it
12504 therefore has no effect, and silently ineffective knobs are what this warning
12505 exists to prevent. See docs/pages/24_Dual_Time_Picard_Jameson_RK.md.
12506 @param[in] cfg Tolerance mapping to inspect for `absolute_tol` and the residual keys.
12507 @param[in] where Config path used to locate the offending key in the warning text.
12508 @return None; emits a warning on stderr when `absolute_tol` cannot take effect.
12509 """
12510 if 'absolute_tol' not in cfg:
12511 return
12512 # Residual convergence is enabled unless BOTH residual tolerances are explicitly
12513 # non-positive; their defaults (1e-8 / 1e-3) are positive.
12514 def _off(key):
12515 """!
12516 @brief Report whether a residual tolerance is explicitly disabled.
12517 @param[in] key Residual tolerance key to inspect.
12518 @return True when the key is present and non-positive.
12519 """
12520 v = cfg.get(key, None)
12521 try:
12522 return v is not None and float(v) <= 0.0
12523 except (TypeError, ValueError):
12524 return False
12525 if _off('residual_absolute_tol') and _off('residual_relative_tol'):
12526 return
12527 print(
12528 f"[WARNING] {where}.absolute_tol is set but takes no part in convergence while a "
12529 "residual tolerance is active (the default). It is retained only for the legacy "
12530 "update-only branch, which can converge falsely when dtau collapses. To control "
12531 "accuracy use residual_relative_tol / residual_absolute_tol. See "
12532 "docs/pages/24_Dual_Time_Picard_Jameson_RK.md.",
12533 file=sys.stderr,
12534 )
12535
12536 ms = solver_cfg.get('momentum_solver', {})
12537 if selected_solver is None:
12538 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
12539 flags['-mom_solver_type'] = f"\"{selected_solver}\""
12540
12541 if 'tolerances' in solver_cfg:
12542 t = solver_cfg['tolerances']
12543 tol_map = {
12544 'max_iterations': '-mom_max_pseudo_steps',
12545 'absolute_tol': '-mom_atol',
12546 'relative_tol': '-mom_rtol',
12547 'residual_absolute_tol': '-mom_resid_atol',
12548 'residual_relative_tol': '-mom_resid_rtol',
12549 'step_tol': '-imp_stol'
12550 }
12551 for key, flag in tol_map.items():
12552 if key in t:
12553 flags[flag] = t[key]
12554 _warn_inactive_absolute_tol(t, "tolerances")
12555
12556 def _append_dualtime_options(cfg: dict):
12557 """!
12558 @brief Append dualtime options.
12559 @param[in] cfg Argument passed to `_append_dualtime_options()`.
12560 """
12561 if 'max_pseudo_steps' in cfg:
12562 flags['-mom_max_pseudo_steps'] = cfg['max_pseudo_steps']
12563 if 'absolute_tol' in cfg:
12564 flags['-mom_atol'] = cfg['absolute_tol']
12565 _warn_inactive_absolute_tol(
12566 {**solver_cfg.get('tolerances', {}), **cfg},
12567 "momentum_solver.dual_time_picard_jameson_rk")
12568 if 'relative_tol' in cfg:
12569 flags['-mom_rtol'] = cfg['relative_tol']
12570 if 'step_tol' in cfg:
12571 flags['-imp_stol'] = cfg['step_tol']
12572 if 'pseudo_cfl' in cfg:
12573 pcfl = cfg['pseudo_cfl']
12574 if 'initial' in pcfl:
12575 flags['-pseudo_cfl'] = pcfl['initial']
12576 if 'minimum' in pcfl:
12577 flags['-min_pseudo_cfl'] = pcfl['minimum']
12578 if 'maximum' in pcfl:
12579 flags['-max_pseudo_cfl'] = pcfl['maximum']
12580 if 'growth_factor' in pcfl:
12581 flags['-pseudo_cfl_growth_factor'] = pcfl['growth_factor']
12582 if 'reduction_factor' in pcfl:
12583 flags['-pseudo_cfl_reduction_factor'] = pcfl['reduction_factor']
12584 if 'jameson_residual_noise_allowance_factor' in cfg:
12585 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['jameson_residual_noise_allowance_factor']
12586 elif 'rk4_residual_noise_allowance_factor' in cfg:
12587 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['rk4_residual_noise_allowance_factor']
12588 if 'ratio_ema_alpha' in cfg:
12589 flags['-mom_ratio_ema_alpha'] = cfg['ratio_ema_alpha']
12590
12591 def _append_newton_krylov_options(cfg: dict):
12592 """!
12593 @brief Append validated structured Newton--Krylov PETSc options.
12594 @param[in] cfg Structured Newton--Krylov mapping.
12595 """
12597 jacobian = cfg["jacobian"]
12598 flags["-mom_nk_jacobian_type"] = jacobian["type"]
12599 flags["-mom_nk_jacobian_fd_mode"] = jacobian["finite_difference"]["mode"]
12600 preconditioner = cfg["preconditioner"]
12601 flags["-mom_nk_preconditioner_model"] = preconditioner["model"]
12602 flags["-mom_nk_preconditioner_structure"] = preconditioner["structure"]["type"]
12603 nonlinear = cfg["nonlinear_solver"]
12604 nonlinear_map = {
12605 "method": "-mom_nk_snes_type",
12606 "absolute_tolerance": "-mom_nk_snes_atol",
12607 "relative_tolerance": "-mom_nk_snes_rtol",
12608 "step_tolerance": "-mom_nk_snes_stol",
12609 "max_iterations": "-mom_nk_snes_max_it",
12610 }
12611 for key, flag in nonlinear_map.items():
12612 if key in nonlinear:
12613 flags[flag] = nonlinear[key]
12614 line_search = nonlinear.get("line_search", {})
12615 if "type" in line_search:
12616 flags["-mom_nk_snes_linesearch_type"] = line_search["type"]
12617 ew = nonlinear.get("eisenstat_walker")
12618 if ew:
12619 flags["-mom_nk_snes_ksp_ew"] = ew["enabled"]
12620 if ew["enabled"]:
12621 ew_map = {
12622 "version": "-mom_nk_snes_ksp_ew_version",
12623 "initial_relative_tolerance": "-mom_nk_snes_ksp_ew_rtol0",
12624 "maximum_relative_tolerance": "-mom_nk_snes_ksp_ew_rtolmax",
12625 "gamma": "-mom_nk_snes_ksp_ew_gamma",
12626 "exponent": "-mom_nk_snes_ksp_ew_alpha",
12627 "safeguard_exponent": "-mom_nk_snes_ksp_ew_alpha2",
12628 "safeguard_threshold": "-mom_nk_snes_ksp_ew_threshold",
12629 }
12630 for key, flag in ew_map.items():
12631 if key in ew:
12632 flags[flag] = ew[key]
12633
12634 linear = cfg["linear_solver"]
12635 linear_map = {
12636 "method": "-mom_nk_ksp_type",
12637 "absolute_tolerance": "-mom_nk_ksp_atol",
12638 "relative_tolerance": "-mom_nk_ksp_rtol",
12639 "max_iterations": "-mom_nk_ksp_max_it",
12640 }
12641 for key, flag in linear_map.items():
12642 if key in linear:
12643 flags[flag] = linear[key]
12644 gmres = linear.get("gmres", {})
12645 if "restart" in gmres:
12646 flags["-mom_nk_ksp_gmres_restart"] = gmres["restart"]
12647
12648 if isinstance(ms, dict):
12649 allowed_ms_keys = {'type', 'dual_time_picard_jameson_rk', 'dual_time_picard_rk4', 'newton_krylov'}
12650 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
12651 if unknown_ms_keys:
12652 raise ValueError(
12653 f"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
12654 "Currently supported blocks: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
12655 )
12656
12657 if 'dual_time_picard_jameson_rk' in ms and 'dual_time_picard_rk4' in ms:
12658 raise ValueError(
12659 "Use only momentum_solver.dual_time_picard_jameson_rk; "
12660 "do not also set its deprecated dual_time_picard_rk4 alias."
12661 )
12662 dt_picard_cfg = ms.get('dual_time_picard_jameson_rk', ms.get('dual_time_picard_rk4'))
12663 if dt_picard_cfg is not None:
12664 if selected_solver != "DUALTIME_PICARD_JAMESON_RK":
12665 raise ValueError(
12666 f"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
12667 )
12668 if not isinstance(dt_picard_cfg, dict):
12669 raise ValueError("momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
12670 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
12671 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
12672 raise ValueError(
12673 "Use only jameson_residual_noise_allowance_factor; "
12674 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
12675 )
12676 _append_dualtime_options(dt_picard_cfg)
12677 newton_cfg = ms.get('newton_krylov')
12678 if newton_cfg is not None:
12679 if selected_solver != "newton_krylov":
12680 raise ValueError(
12681 f"momentum_solver.newton_krylov is set but selected solver is {selected_solver}."
12682 )
12683 _append_newton_krylov_options(newton_cfg)
12684 def _normalize_poisson_method(value) -> str:
12685 """!
12686 @brief Normalize a user-facing Poisson linear-solver method name.
12687 @param[in] value Method value from the solver YAML.
12688 @return Lowercase PETSc KSP method token.
12689 """
12690 method = str(value).strip().lower()
12691 if not method:
12692 raise ValueError("poisson_solver.method cannot be empty.")
12693 return method
12694
12695 def _normalize_poisson_preconditioner(value) -> str:
12696 """!
12697 @brief Normalize and validate the outer Poisson preconditioner name.
12698 @param[in] value Preconditioner value from the solver YAML.
12699 @return PETSc PC token for the supported outer preconditioner.
12700 """
12701 pc = str(value).strip().lower()
12702 pc = POISSON_PRECONDITIONER_SPELLINGS.get(pc, pc)
12703 if pc not in POISSON_PRECONDITIONER_TYPES:
12704 raise ValueError(
12705 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
12706 "The runtime Poisson solver still assumes PETSc PCMG setup."
12707 )
12708 return "mg"
12709
12710 def _warn_if_krylov_coarse_solver(ksp_type, source_key: str):
12711 """!
12712 @brief Warn when the coarsest multigrid level is given a Krylov solver.
12713 @param[in] ksp_type PETSc KSP token configured for `level_0`.
12714 @param[in] source_key Name of the source YAML block, used in the message.
12715 @details level_0 is the coarse solve at the base of the V-cycle, not a
12716 smoother. A Krylov method there makes the multigrid
12717 preconditioner a nonlinear operator, which decouples the outer
12718 KSP's tracked residual from the true residual b-Ax. This stays a
12719 warning rather than an error because it remains legitimate at
12720 large scale when tolerances are set against the true residual.
12721 """
12722 if str(ksp_type).strip().lower() not in KRYLOV_KSP_TYPES:
12723 return
12724 print(
12725 f"[WARNING] {source_key}.multigrid.level_solvers.level_0.method = '{ksp_type}' "
12726 "is a Krylov method. level_0 is the multigrid COARSE SOLVE, not a smoother, "
12727 "so a Krylov method there makes the preconditioner nonlinear and the outer "
12728 "KSP's tracked residual can stop matching the true residual b-Ax. "
12729 "Prefer {method: preonly, preconditioner: redundant}. If this is deliberate, "
12730 "enable solver_monitoring.poisson.pic_true_residual and set tolerances against "
12731 "the true residual. See docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.",
12732 file=sys.stderr,
12733 )
12734
12735 def _poisson_level_number(level_name) -> int:
12736 """!
12737 @brief Extract the numeric suffix from a `level_N` multigrid level key.
12738 @param[in] level_name YAML level key supplied by the user.
12739 @return Numeric level suffix.
12740 """
12741 text = str(level_name).strip()
12742 match = re.fullmatch(r"level_(\d+)", text)
12743 if not match:
12744 raise ValueError(f"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
12745 return int(match.group(1))
12746
12747 def _append_poisson_solver_flags(ps: dict, source_key: str):
12748 """!
12749 @brief Append structured Poisson solver options to the flat PETSc flag map.
12750 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
12751 @param[in] source_key Name of the source YAML block, used in error messages.
12752 """
12753 if not isinstance(ps, dict):
12754 raise ValueError(f"{source_key} must be a mapping when provided.")
12755
12756 method = None
12757 if 'method' in ps:
12758 method = _normalize_poisson_method(ps['method'])
12759 flags['-ps_ksp_type'] = method
12760 if 'absolute_tolerance' in ps:
12761 flags['-ps_ksp_atol'] = ps['absolute_tolerance']
12762 flags['-poisson_tol'] = ps['absolute_tolerance']
12763 if 'relative_tolerance' in ps:
12764 flags['-ps_ksp_rtol'] = ps['relative_tolerance']
12765 if 'max_iterations' in ps:
12766 flags['-ps_ksp_max_it'] = ps['max_iterations']
12767 if 'tolerance' in ps:
12768 flags['-poisson_tol'] = ps['tolerance']
12769
12770 gmres_cfg = ps.get('gmres', {})
12771 if gmres_cfg is not None:
12772 if not isinstance(gmres_cfg, dict):
12773 raise ValueError(f"{source_key}.gmres must be a mapping when provided.")
12774 if 'restart' in gmres_cfg:
12775 if method is None:
12776 method = _normalize_poisson_method(ps.get('method', 'fgmres'))
12777 flags.setdefault('-ps_ksp_type', method)
12778 if method not in GMRES_RESTART_METHODS:
12779 raise ValueError(
12780 f"{source_key}.gmres.restart is valid only when {source_key}.method "
12781 "is one of 'gmres', 'fgmres', or 'lgmres'."
12782 )
12783 flags['-ps_ksp_gmres_restart'] = gmres_cfg['restart']
12784
12785 preconditioner_cfg = ps.get('preconditioner', {})
12786 if preconditioner_cfg:
12787 if not isinstance(preconditioner_cfg, dict):
12788 raise ValueError(f"{source_key}.preconditioner must be a mapping when provided.")
12789 if 'type' in preconditioner_cfg:
12790 flags['-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg['type'])
12791
12792 if 'multigrid' in ps:
12793 mg = ps['multigrid']
12794 if not isinstance(mg, dict):
12795 raise ValueError(f"{source_key}.multigrid must be a mapping when provided.")
12796 mg_map = {'levels': '-mg_level', 'pre_sweeps': '-mg_pre_it', 'post_sweeps': '-mg_post_it'}
12797 for key, flag in mg_map.items():
12798 if key in mg: flags[flag] = mg[key]
12799 if 'cycle' in mg:
12800 cycle = str(mg['cycle']).strip().lower()
12801 if cycle not in {"v"}:
12802 raise ValueError(f"{source_key}.multigrid.cycle currently supports only 'v'.")
12803 if 'mode' in mg:
12804 mode = str(mg['mode']).strip().lower()
12805 if mode not in {"multiplicative"}:
12806 raise ValueError(f"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
12807 if 'semi_coarsening' in mg:
12808 sc = mg['semi_coarsening']
12809 if not isinstance(sc, dict):
12810 raise ValueError(f"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
12811 if 'i' in sc: flags['-mg_i_semi'] = format_flag_value(sc['i'])
12812 if 'j' in sc: flags['-mg_j_semi'] = format_flag_value(sc['j'])
12813 if 'k' in sc: flags['-mg_k_semi'] = format_flag_value(sc['k'])
12814 if 'level_solvers' in mg:
12815 level_solvers = mg['level_solvers']
12816 if not isinstance(level_solvers, dict):
12817 raise ValueError(f"{source_key}.multigrid.level_solvers must be a mapping when provided.")
12818 for level_name, settings in level_solvers.items():
12819 if not isinstance(settings, dict):
12820 raise ValueError(f"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
12821 level_num = _poisson_level_number(level_name)
12822 for key, value in settings.items():
12823 mapped_key = {'method': 'ksp_type', 'preconditioner': 'pc_type'}.get(key, key)
12824 # PETSc names the coarsest solver separately from positive levels.
12825 if level_num == 0:
12826 prefix = "-ps_mg_coarse_"
12827 if mapped_key == 'ksp_type':
12828 _warn_if_krylov_coarse_solver(value, source_key)
12829 else:
12830 prefix = f"-ps_mg_levels_{level_num}_"
12831 flags[f"{prefix}{mapped_key}"] = format_flag_value(value)
12832
12833 if 'poisson_solver' in solver_cfg and 'pressure_solver' in solver_cfg:
12834 if solver_cfg['poisson_solver'] != solver_cfg['pressure_solver']:
12835 raise ValueError(
12836 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
12837 "Use 'poisson_solver' only, or make the legacy alias identical."
12838 )
12839 poisson_cfg = solver_cfg.get('poisson_solver', solver_cfg.get('pressure_solver'))
12840 if poisson_cfg is not None:
12841 source_key = 'poisson_solver' if 'poisson_solver' in solver_cfg else 'pressure_solver'
12842 _append_poisson_solver_flags(poisson_cfg, source_key)
12843 interp_cfg = solver_cfg.get('interpolation', {})
12844 if isinstance(interp_cfg, dict):
12845 interp_method_str = interp_cfg.get('method', 'Trilinear')
12846 else:
12847 interp_method_str = 'Trilinear'
12848 interp_code = normalize_interpolation_method(interp_method_str)
12849 flags['-interpolation_method'] = interp_code
12850 print(f" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
12851
12852 if 'petsc_passthrough_options' in solver_cfg:
12853 passthrough = solver_cfg['petsc_passthrough_options']
12854 if passthrough is None:
12855 passthrough = {}
12856 if not isinstance(passthrough, dict):
12857 raise ValueError("petsc_passthrough_options must be a mapping when provided.")
12858 if passthrough:
12859 for key, value in passthrough.items():
12860 if str(key).strip() == "-ps_mg_coarse_ksp_type":
12861 _warn_if_krylov_coarse_solver(value, "petsc_passthrough_options")
12862 flags[key] = value if isinstance(value, bool) else format_flag_value(value)
12863 summary_bits = []
12864 if '-ps_ksp_type' in flags:
12865 summary_bits.append(f"method={flags['-ps_ksp_type']}")
12866 if '-ps_ksp_atol' in flags:
12867 summary_bits.append(f"atol={flags['-ps_ksp_atol']}")
12868 if '-ps_ksp_rtol' in flags:
12869 summary_bits.append(f"rtol={flags['-ps_ksp_rtol']}")
12870 if '-ps_ksp_max_it' in flags:
12871 summary_bits.append(f"max_it={flags['-ps_ksp_max_it']}")
12872 if '-mg_level' in flags:
12873 summary_bits.append(f"mg_levels={flags['-mg_level']}")
12874 if summary_bits:
12875 print(f" - Poisson Solver: {', '.join(summary_bits)}")
12876 if selected_solver == "DUALTIME_PICARD_JAMESON_RK":
12877 dualtime_bits = []
12878 for label, flag in (
12879 ("initial_pseudo_cfl", "-pseudo_cfl"),
12880 ("pseudo_cfl_range", None),
12881 ("max_pseudo_steps", "-mom_max_pseudo_steps"),
12882 ):
12883 if flag and flag in flags:
12884 dualtime_bits.append(f"{label}={flags[flag]}")
12885 elif label == "pseudo_cfl_range" and "-min_pseudo_cfl" in flags and "-max_pseudo_cfl" in flags:
12886 dualtime_bits.append(f"pseudo_cfl_range=[{flags['-min_pseudo_cfl']}, {flags['-max_pseudo_cfl']}]")
12887 print(" - Momentum Solver: Dual Time Picard Jameson RK" +
12888 (f" ({', '.join(dualtime_bits)})" if dualtime_bits else ""))
12889 elif selected_solver == "newton_krylov":
12890 newton_bits = []
12891 for label, flag in (
12892 ("jacobian", "-mom_nk_jacobian_type"),
12893 ("nonlinear", "-mom_nk_snes_type"),
12894 ("linear", "-mom_nk_ksp_type"),
12895 ("preconditioner", "-mom_nk_preconditioner_model"),
12896 ):
12897 if flag in flags:
12898 newton_bits.append(f"{label}={flags[flag]}")
12899 print(" - Momentum Solver: Newton Krylov" +
12900 (f" ({', '.join(newton_bits)})" if newton_bits else " (PETSc defaults)"))
12901 else:
12902 print(" - Momentum Solver: Explicit RK (no pseudo-time controller)")
12903 return flags
12904
12905def generate_solver_control_file(run_dir, run_id, configs, num_procs, monitor_files,
12906 restart_source_dir=None, continue_mode=False,
12907 config_dir: str = None):
12908 """!
12909 @brief Generates the main .control file for the C-solver.
12910 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
12911 into a single, machine-readable file of command-line flags.
12912 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
12913 @param[in] run_id Argument passed to `generate_solver_control_file()`.
12914 @param[in] configs Argument passed to `generate_solver_control_file()`.
12915 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
12916 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
12917 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
12918 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
12919 @param[in] config_dir Optional configuration revision directory.
12920 @return Value returned by `generate_solver_control_file()`.
12921 """
12922 print("[INFO] Generating master solver control file...")
12923 case_cfg, solver_cfg, monitor_cfg = configs['case'], configs['solver'], configs['monitor']
12924 source_files = {'Case': configs['case_path'], 'Solver': configs['solver_path'], 'Monitor': configs['monitor_path']}
12925
12926 control_lines = []
12927 try:
12928 props, run_ctrl = case_cfg['properties'], case_cfg['run_control']
12929 scales, fluid, ic = props['scaling'], props['fluid'], props['initial_conditions']
12930 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
12931 fluid_scaling = resolve_fluid_scaling(case_cfg)
12932 L_ref = fluid_scaling["length_ref"]
12933 U_ref = fluid_scaling["velocity_ref"]
12934 rho = fluid_scaling["density"]
12935 reynolds = fluid_scaling["reynolds"]
12936 dt_phys = float(run_ctrl['dt_physical'])
12937 T_ref = L_ref / U_ref if U_ref != 0 else float('inf')
12938 dt_nondim = dt_phys / T_ref if T_ref != float('inf') else 0.0
12939 print(f" - Reynolds Number (Re) = {reynolds:.4f}")
12940 print(f" - Non-Dimensional dt* = {dt_nondim:.6f}")
12941 eulerian_source = normalize_eulerian_field_source(
12942 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
12943 )
12944 start_step = int(run_ctrl.get("start_step", 0) or 0)
12945 ic_is_authoritative = eulerian_source == "solve" and start_step == 0
12946 ic_cli = []
12947 if ic_is_authoritative:
12949 ic, prepared_blocks, U_ref,
12950 provider_context={"kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]},
12951 )
12952 finit_mode_str = resolved_ic["label"]
12953 finit_code = resolved_ic["finit"]
12954 ic_params = resolved_ic["cli_params"]
12955 print(f" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
12956 if "ucont_x" in ic_params:
12957 ic_cli.extend([
12958 f"-ucont_x {ic_params['ucont_x']}",
12959 f"-ucont_y {ic_params['ucont_y']}",
12960 f"-ucont_z {ic_params['ucont_z']}",
12961 ])
12962 if "ic_velocity_physical" in ic_params:
12963 ic_cli.append(f"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
12964 if "flow_direction" in ic_params:
12965 ic_cli.append(f"-flow_direction {ic_params['flow_direction']}")
12966 else:
12967 print(
12968 f"[WARN] Ignoring configured initial condition because "
12969 f"eulerian_field_source={eulerian_source!r} and start_step={start_step} select another source.",
12970 file=sys.stderr,
12971 )
12972 finit_code = 0
12973 resolved_ic = None
12974 control_lines.extend([
12975 f"-start_step {run_ctrl['start_step']}", f"-totalsteps {run_ctrl['total_steps']}",
12976 f"-ren {reynolds}", f"-dt {dt_nondim}", f"-finit {finit_code}",
12977 *ic_cli,
12978 f"-scaling_L_ref {L_ref}", f"-scaling_U_ref {U_ref}", f"-scaling_rho_ref {rho}"
12979 ])
12980 except (KeyError, TypeError, ZeroDivisionError, ValueError) as e:
12981 print(f"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
12982 sys.exit(1)
12983
12984 # --- CORRECTED: Add paths for whitelist and profile files ---
12985 if monitor_files.get("whitelist"):
12986 control_lines.append(f"-whitelist_config_file {monitor_files['whitelist']}")
12987 if monitor_files.get("profile"):
12988 control_lines.append(f"-profile_config_file {monitor_files['profile']}")
12990 control_lines.extend(resolve_field_statistics_flags(monitor_cfg, case_cfg))
12991 profiling_cfg = monitor_files.get("profiling", {})
12992 control_lines.append(f"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
12993 if profiling_cfg.get("mode") != "off":
12994 control_lines.append(f"-profiling_timestep_file {profiling_cfg.get('timestep_file', 'Profiling_Timestep_Summary.csv')}")
12995 control_lines.append(f"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
12996 diagnostics_cfg = resolve_diagnostics_config(monitor_cfg)
12997 memory_log_cfg = diagnostics_cfg["runtime_memory_log"]
12998 control_lines.append(f"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
12999 control_lines.append(f"-runtime_memory_log_file {memory_log_cfg.get('file', 'Runtime_Memory.log')}")
13000
13001 walltime_guard_policy = configs.get("walltime_guard_policy")
13002 if walltime_guard_policy is not None:
13003 control_lines.extend(
13004 [
13005 f"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
13006 f"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
13007 f"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
13008 f"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
13009 f"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
13010 ]
13011 )
13012
13013 grid_cfg = case_cfg.get('grid', {})
13014 grid_mode = grid_cfg.get('mode')
13015 expected_nblk = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
13016
13017 if grid_mode == 'file':
13018 print("[INFO] Grid Mode: Using external file...")
13019 case_file_dir = os.path.dirname(configs['case_path'])
13020 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
13021 if os.path.isfile(nondim_grid_path):
13022 print(f"[INFO] Reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
13023 control_lines.append(f"-grid_file {nondim_grid_path}")
13024 else:
13025 source_grid = _resolve_case_relative_path(grid_cfg['source_file'], case_file_dir)
13026 grid_for_validation = source_grid
13027 try:
13029 grid_for_validation, nondim_grid_path, L_ref, expected_nblk=expected_nblk
13030 )
13031 print(
13032 f"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
13033 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
13034 )
13035 control_lines.append(f"-grid_file {nondim_grid_path}")
13036 except Exception as e:
13037 print(f"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
13038 sys.exit(1)
13039 elif grid_mode == 'grid_gen':
13040 print("[INFO] Grid Mode: Generating external grid via grid.gen...")
13041 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
13042 if os.path.isfile(nondim_grid_path):
13043 print(f"[INFO] Reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
13044 control_lines.append(f"-grid_file {nondim_grid_path}")
13045 else:
13046 try:
13047 # grid.gen already converts ncells_* inputs into node-count PICGRID dims.
13048 generated_grid = run_grid_generator(
13049 configs['case_path'], run_dir, grid_cfg, case_cfg=case_cfg)
13051 generated_grid, nondim_grid_path, L_ref, expected_nblk=expected_nblk
13052 )
13053 print(
13054 f"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
13055 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
13056 )
13057 control_lines.append(f"-grid_file {nondim_grid_path}")
13058 except Exception as e:
13059 print(f"[FATAL] Grid generation failed: {e}", file=sys.stderr)
13060 sys.exit(1)
13061 elif grid_mode == 'programmatic_c':
13062 print("[INFO] Grid Mode: Programmatic C...")
13063 grid_settings = translate_programmatic_grid_settings(grid_cfg.get('programmatic_settings', {}))
13064 control_lines.append("-grid")
13065 for p_key in GRID_DA_PROCESSOR_KEYS:
13066 grid_settings.pop(p_key, None)
13067 for key, value in grid_settings.items(): control_lines.append(f"-{key} {format_flag_value(value)}")
13068 if resolved_ic and is_generated_ic_provider(resolved_ic) and ic_is_authoritative:
13069 # The simulator builds this grid itself and never reads a file for it; this
13070 # bridge exists only so the Python IC generator (a separate process) can see
13071 # the same node coordinates. generate_picgrid_from_programmatic_settings uses
13072 # the identical formula as ComputeStretchedCoord in src/grid.c, so the two are
13073 # guaranteed to agree. Nothing above is changed: -grid_file is never emitted,
13074 # so the solver still builds its own grid exactly as before.
13075 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
13076 if not os.path.isfile(nondim_grid_path):
13077 try:
13079 grid_cfg.get('programmatic_settings', {}), nondim_grid_path, L_ref
13080 )
13081 print(
13082 "[SUCCESS] Materialized a bridge grid for the Python initial-condition "
13083 f"provider: {os.path.relpath(nondim_grid_path)}"
13084 )
13085 except Exception as e:
13086 print(f"[FATAL] Failed to materialize bridge grid for initial-condition generator: {e}",
13087 file=sys.stderr)
13088 sys.exit(1)
13089 else:
13090 raise ValueError(f"Unknown or missing grid mode '{grid_mode}' in case.yml.")
13091
13092 if resolved_ic and (resolved_ic["kind"] == "file" or is_generated_ic_provider(resolved_ic)):
13093 try:
13094 staged_ic = stage_initial_condition_file(run_dir, configs["case_path"], resolved_ic)
13095 except Exception as e:
13096 print(f"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
13097 sys.exit(1)
13098 control_lines.extend([
13099 f"-ic_field {resolved_ic['field_code']}",
13100 f"-ic_dir {staged_ic['directory']}",
13101 ])
13102 print(f" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
13103
13104 try:
13105 bcs_files = generate_multi_block_bcs(
13106 run_dir, run_id, case_cfg, source_files, config_dir=config_dir
13107 )
13108 except ValueError as e:
13109 print(f"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
13110 sys.exit(1)
13111 control_lines.append(f"-bcs_files \"{','.join(bcs_files)}\"")
13112
13113 append_grid_da_processor_layout(control_lines, grid_cfg, num_procs)
13114
13115 parse_and_add_model_flags(case_cfg, control_lines)
13116
13117 if 'solver_parameters' in case_cfg:
13118 params = case_cfg['solver_parameters']
13119 if params:
13120 for key, value in params.items():
13121 control_lines.append(f"{key} {format_flag_value(value)}")
13122
13123 try:
13124 solver_flags = parse_solver_config(solver_cfg)
13125 except ValueError as e:
13126 print(f"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
13127 sys.exit(1)
13128 append_passthrough_flags(control_lines, solver_flags)
13129
13130 try:
13131 solver_monitoring_flags = resolve_solver_monitoring_flags(monitor_cfg)
13132 except ValueError as e:
13133 print(f"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
13134 sys.exit(1)
13135 append_passthrough_flags(control_lines, solver_monitoring_flags)
13136
13137 io_cfg = monitor_cfg.get('io', {})
13138 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
13139 if 'data_output_frequency' in io_cfg: control_lines.append(f"-tio {io_cfg['data_output_frequency']}")
13140 if particle_console_output_freq is not None:
13141 control_lines.append(f"-particle_console_output_freq {particle_console_output_freq}")
13142 statistics_console_output_freq = resolve_statistics_console_output_frequency(io_cfg)
13143 if statistics_console_output_freq is not None:
13144 control_lines.append(f"-statistics_console_output_freq {statistics_console_output_freq}")
13145 if 'particle_log_interval' in io_cfg: control_lines.append(f"-logfreq {io_cfg['particle_log_interval']}")
13146 if restart_source_dir:
13147 expected_restart = os.path.abspath(
13148 os.path.join(run_dir, CANONICAL_RUN_PATHS["restart"])
13149 )
13150 if os.path.abspath(restart_source_dir) != expected_restart:
13151 raise ValueError(
13152 "Restart data was not materialized in the canonical run input: "
13153 f"expected {expected_restart}, got {restart_source_dir}."
13154 )
13155 if continue_mode:
13156 control_lines.append("-continue_mode true")
13157 elif str(configs.get("statistics_state", "reset")).lower() == "carry":
13158 control_lines.append("-field_statistics_continue true")
13159
13160 # Emitted last, and always: PETSc takes the final occurrence of an option, so a
13161 # validated directory written here wins over anything earlier in the file. Writing
13162 # them unconditionally also removes the "omitted default" gap, where an absent flag
13163 # let an environment variable or PETSc configuration choose the directory instead.
13164 control_lines.append("")
13165 control_lines.append("# Canonical run-owned directories are fixed by the workspace contract.")
13166 control_lines.append(f"-output_dir {CANONICAL_RUN_PATHS['output']}")
13167 control_lines.append(f"-restart_dir {CANONICAL_RUN_PATHS['restart']}")
13168 control_lines.append(f"-log_dir {CANONICAL_RUN_PATHS['logs']}")
13169 control_lines.append(f"-analysis_dir {CANONICAL_RUN_PATHS['metrics']}")
13170
13171 final_content = generate_header(run_id, source_files) + "\n".join(control_lines)
13172 config_dir = config_dir or os.path.join(run_dir, "config")
13173 os.makedirs(config_dir, exist_ok=True)
13174 control_file_path = os.path.join(config_dir, f"{run_id}.control")
13175 with open(control_file_path, "w") as f: f.write(final_content)
13176 print(f"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
13177 return os.path.abspath(control_file_path)
13178
13179def generate_post_recipe_file(run_dir: str, run_id: str, post_cfg: dict, source_files: dict, monitor_cfg=None) -> str:
13180 """!
13181 @brief Generates a key=value config file (post.run) for the C post-processor.
13182 @details Translates the structured post-processing YAML into the specific flat
13183 key-value format required by the C executable, including complex,
13184 semicolon-separated pipeline strings.
13185 @param[in] run_dir The path to the main run directory.
13186 @param[in] run_id The unique identifier for the run.
13187 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
13188 @param[in] source_files A dictionary of source files for the header.
13189 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
13190 @return The absolute path to the generated post.run recipe file.
13191 """
13192 print("[INFO] Generating post-processor recipe file (post.run)...")
13193 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
13194 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
13195 config_dir = get_post_recipe_root(run_dir, post_cfg)
13196 os.makedirs(config_dir, exist_ok=True)
13197 post_recipe_path = os.path.join(config_dir, "post.run")
13198
13199 lines = [generate_header(run_id, source_files)]
13200 c_config = build_post_recipe_config(post_cfg, monitor_cfg)
13201
13202 for key, value in c_config.items():
13203 if value is not None and str(value) != "":
13204 lines.append(f"{key} = {value}")
13205
13206 with open(post_recipe_path, "w") as f:
13207 f.write("\n".join(lines))
13208 print(f"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
13209 return os.path.abspath(post_recipe_path)
13210
13211def execute_command(command: list, run_dir: str, log_filename: str, monitor_cfg: dict = None):
13212 """!
13213 @brief Executes a command, streaming its output to the console and a log file.
13214 @details ...
13215 If None, the process inherits the parent's environment directly.
13216 @param[in] command Argument passed to `execute_command()`.
13217 @param[in] run_dir Argument passed to `execute_command()`.
13218 @param[in] log_filename Argument passed to `execute_command()`.
13219 @param[in] monitor_cfg Argument passed to `execute_command()`.
13220 """
13221 log_path = resolve_command_log_path(run_dir, log_filename)
13222 os.makedirs(os.path.dirname(log_path), exist_ok=True)
13223
13224 print(f"[INFO] Launching Command...\n > {format_command_for_display(command)}")
13225 print(f" Log file: {os.path.relpath(log_path)}")
13226 print("-" * 60)
13227
13228 # --- Environment Handling ---
13229 popen_kwargs = {
13230 "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT,
13231 "cwd": run_dir, "bufsize": 1, "universal_newlines": True,
13232 "encoding": 'utf-8', "errors": 'replace'
13233 }
13234
13235 if monitor_cfg:
13236 print("[INFO] Creating custom environment to set LOG_LEVEL.")
13237 run_env = os.environ.copy()
13238 verbosity = monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()
13239 run_env['LOG_LEVEL'] = verbosity
13240 print(f"[INFO] Setting LOG_LEVEL={verbosity} for C executable.")
13241 popen_kwargs['env'] = run_env
13242 else:
13243 print("[INFO] Using inherited environment for process.")
13244
13245 print("-" * 60)
13246 try:
13247 # Pass the constructed keyword arguments dictionary to Popen
13248 process = subprocess.Popen(command, **popen_kwargs)
13249
13250 with open(log_path, "w") as log_file:
13251 for line in process.stdout:
13252 sys.stdout.write(line)
13253 log_file.write(line)
13254 process.wait()
13255 return_code = process.returncode
13256 print("-" * 60)
13257 if return_code == 0:
13258 print(f"[SUCCESS] Execution finished successfully.")
13259 else:
13260 print(f"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
13261 sys.exit(return_code)
13262 except FileNotFoundError:
13263 print(f"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
13264 print(" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
13265 sys.exit(1)
13266 except Exception as e:
13267 print(f"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
13268 sys.exit(1)
13269
13270
13271def format_command_for_display(command: list) -> str:
13272 """!
13273 @brief Render a shell-safe command string for console and log output.
13274 @param[in] command Argument passed to `format_command_for_display()`.
13275 @return Value returned by `format_command_for_display()`.
13276 """
13277 return " ".join(shlex.quote(str(part)) for part in command)
13278
13279
13280def resolve_command_log_path(run_dir: str, log_filename: str) -> str:
13281 """!
13282 @brief Resolve a command log filename relative to the run directory.
13283 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
13284 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
13285 @return Value returned by `resolve_command_log_path()`.
13286 """
13287 if os.path.dirname(log_filename):
13288 return os.path.join(run_dir, log_filename)
13289 return os.path.join(run_dir, "logs", log_filename)
13290
13291
13292class CommandExecutionError(RuntimeError):
13293 """!
13294 @brief Raised when an external command exits unsuccessfully.
13295 """
13296
13297 def __init__(self, command: list, returncode: int, details: str = None):
13298 """!
13299 @brief Initialize a command execution error.
13300 @param[in] command Argument passed to `__init__()`.
13301 @param[in] returncode Argument passed to `__init__()`.
13302 @param[in] details Argument passed to `__init__()`.
13303 """
13304 self.command = command
13305 self.returncode = returncode
13306 self.details = details
13307 detail_suffix = f": {details}" if details else ""
13308 super().__init__(
13309 f"Command failed with exit code {returncode}: {format_command_for_display(command)}{detail_suffix}"
13310 )
13311
13312
13313class PlotDependencyError(RuntimeError):
13314 """!
13315 @brief Raised when plot.gen reports a missing optional dependency.
13316 """
13317
13318
13319def _run_captured_command(command: list, run_dir: str) -> subprocess.CompletedProcess:
13320 """!
13321 @brief Run a command and capture combined stdout/stderr details for later inspection.
13322 @param[in] command Argument passed to `_run_captured_command()`.
13323 @param[in] run_dir Argument passed to `_run_captured_command()`.
13324 @return Value returned by `_run_captured_command()`.
13325 """
13326 try:
13327 return subprocess.run(
13328 command,
13329 cwd=run_dir,
13330 text=True,
13331 capture_output=True,
13332 check=False,
13333 encoding="utf-8",
13334 errors="replace",
13335 )
13336 except FileNotFoundError as exc:
13337 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
13338
13339
13340def _require_successful_command(command: list, result: subprocess.CompletedProcess):
13341 """!
13342 @brief Raise `CommandExecutionError` when a captured command failed.
13343 @param[in] command Argument passed to `_require_successful_command()`.
13344 @param[in] result Argument passed to `_require_successful_command()`.
13345 """
13346 if result.returncode == 0:
13347 return
13348 details = (result.stderr or result.stdout).strip()
13349 raise CommandExecutionError(command, result.returncode, details or None)
13350
13351
13352def _capture_command_stdout(command: list, run_dir: str) -> str:
13353 """!
13354 @brief Run a command, require success, and return stripped stdout text.
13355 @param[in] command Argument passed to `_capture_command_stdout()`.
13356 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
13357 @return Value returned by `_capture_command_stdout()`.
13358 """
13359 result = _run_captured_command(command, run_dir)
13360 _require_successful_command(command, result)
13361 return result.stdout.strip()
13362
13363
13364def _stream_command_to_console_and_log(command: list, run_dir: str, log_file):
13365 """!
13366 @brief Stream command output to stdout and an already-open log file.
13367 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
13368 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
13369 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
13370 """
13371 display = format_command_for_display(command)
13372 print(f"[INFO] Running: {display}")
13373 log_file.write(f"$ {display}\n")
13374 log_file.flush()
13375
13376 popen_kwargs = {
13377 "stdout": subprocess.PIPE,
13378 "stderr": subprocess.STDOUT,
13379 "cwd": run_dir,
13380 "bufsize": 1,
13381 "universal_newlines": True,
13382 "encoding": "utf-8",
13383 "errors": "replace",
13384 }
13385
13386 try:
13387 process = subprocess.Popen(command, **popen_kwargs)
13388 except FileNotFoundError as exc:
13389 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
13390
13391 with process:
13392 for line in process.stdout:
13393 sys.stdout.write(line)
13394 log_file.write(line)
13395 return_code = process.wait()
13396 log_file.write("\n")
13397 log_file.flush()
13398 if return_code != 0:
13399 raise CommandExecutionError(command, return_code)
13400
13401
13402def _get_git_head_state(run_dir: str) -> dict:
13403 """!
13404 @brief Capture the current git HEAD branch name and commit hash.
13405 @param[in] run_dir Argument passed to `_get_git_head_state()`.
13406 @return Value returned by `_get_git_head_state()`.
13407 """
13408 head_commit = _capture_command_stdout(["git", "rev-parse", "--verify", "HEAD"], run_dir)
13409 branch_result = _run_captured_command(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], run_dir)
13410 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
13411 return {"branch": branch_name, "commit": head_commit}
13412
13413
13414def _get_local_branches_with_upstreams(run_dir: str) -> "list[tuple[str, str | None]]":
13415 """!
13416 @brief Return local branch names plus their configured upstreams.
13417 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
13418 @return Value returned by `_get_local_branches_with_upstreams()`.
13419 """
13420 output = _capture_command_stdout(
13421 ["git", "for-each-ref", "--sort=refname", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
13422 run_dir,
13423 )
13424 branches = []
13425 for line in output.splitlines():
13426 if not line.strip():
13427 continue
13428 branch_name, _, upstream_name = line.partition("\t")
13429 branches.append((branch_name, upstream_name or None))
13430 return branches
13431
13432
13433def _working_tree_has_tracked_changes(run_dir: str) -> bool:
13434 """!
13435 @brief Return `True` when the repository has staged or unstaged tracked changes.
13436 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
13437 @return Value returned by `_working_tree_has_tracked_changes()`.
13438 """
13439 command = ["git", "status", "--porcelain", "--untracked-files=no"]
13440 result = _run_captured_command(command, run_dir)
13441 _require_successful_command(command, result)
13442 return bool(result.stdout.strip())
13443
13444
13445def _attempt_pull_cleanup(run_dir: str, rebase: bool, log_file):
13446 """!
13447 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
13448 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
13449 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
13450 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
13451 """
13452 cleanup_command = ["git", "rebase", "--abort"] if rebase else ["git", "merge", "--abort"]
13453 result = _run_captured_command(cleanup_command, run_dir)
13454 if result.returncode == 0:
13455 print(f"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
13456 log_file.write(f"$ {format_command_for_display(cleanup_command)}\n")
13457 if result.stdout:
13458 sys.stdout.write(result.stdout)
13459 log_file.write(result.stdout)
13460 if result.stderr:
13461 sys.stderr.write(result.stderr)
13462 log_file.write(result.stderr)
13463 log_file.write("\n")
13464 log_file.flush()
13465 return
13466
13467 details = (result.stderr or result.stdout).strip()
13468 if details:
13469 message = (
13470 f"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
13471 )
13472 print(message, file=sys.stderr)
13473 log_file.write(message + "\n")
13474 log_file.flush()
13475
13476
13477def _restore_git_head(run_dir: str, original_head: dict, log_file):
13478 """!
13479 @brief Restore the repository back to the branch or detached commit it started on.
13480 @param[in] run_dir Argument passed to `_restore_git_head()`.
13481 @param[in] original_head Argument passed to `_restore_git_head()`.
13482 @param[in] log_file Argument passed to `_restore_git_head()`.
13483 """
13484 current_state = _get_git_head_state(run_dir)
13485 if original_head["branch"]:
13486 if current_state["branch"] == original_head["branch"]:
13487 return
13488 _stream_command_to_console_and_log(["git", "checkout", original_head["branch"]], run_dir, log_file)
13489 return
13490
13491 if current_state["branch"] is None and current_state["commit"] == original_head["commit"]:
13492 return
13493 _stream_command_to_console_and_log(["git", "checkout", "--detach", original_head["commit"]], run_dir, log_file)
13494
13495
13496def pull_all_source_branches(run_dir: str, log_filename: str, rebase: bool = True):
13497 """!
13498 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
13499 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
13500 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
13501 @param[in] rebase Argument passed to `pull_all_source_branches()`.
13502 """
13503 log_path = resolve_command_log_path(run_dir, log_filename)
13504 os.makedirs(os.path.dirname(log_path), exist_ok=True)
13505
13506 print("\n" + "="*23 + " PULL SOURCE STAGE " + "="*22)
13507 print("[INFO] Refreshing all local source branches that track an upstream.")
13508 print(f" Log file: {os.path.relpath(log_path)}")
13509 print("-" * 60)
13510
13511 try:
13512 original_head = _get_git_head_state(run_dir)
13514 raise RuntimeError(
13515 "Multi-branch pull requires a clean tracked working tree in the source repository. "
13516 "Commit or stash those changes first, or rerun with --current-branch-only."
13517 )
13518 branches = _get_local_branches_with_upstreams(run_dir)
13519 except (CommandExecutionError, RuntimeError) as exc:
13520 print(f"[FATAL] {exc}", file=sys.stderr)
13521 sys.exit(getattr(exc, "returncode", 1))
13522
13523 if not branches:
13524 print("[FATAL] No local branches were found in the source repository.", file=sys.stderr)
13525 sys.exit(1)
13526
13527 if original_head["branch"]:
13528 branches = [item for item in branches if item[0] != original_head["branch"]] + [
13529 item for item in branches if item[0] == original_head["branch"]
13530 ]
13531
13532 skipped_branches = []
13533 current_operation = None
13534 pull_error = None
13535 restore_error = None
13536
13537 with open(log_path, "w", encoding="utf-8") as log_file:
13538 log_file.write(f"# PICurv pull-source all-branch sync\n")
13539 log_file.write(f"# repository: {os.path.abspath(run_dir)}\n")
13540 log_file.write(f"# started: {datetime.now().isoformat()}\n")
13541 log_file.write(
13542 f"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
13543 )
13544
13545 try:
13546 for branch_name, upstream_name in branches:
13547 if not upstream_name:
13548 warning = f"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
13549 print(warning, file=sys.stderr)
13550 log_file.write(warning + "\n")
13551 skipped_branches.append(branch_name)
13552 continue
13553
13554 print(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
13555 log_file.write(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
13556
13557 current_operation = f"checkout:{branch_name}"
13558 _stream_command_to_console_and_log(["git", "checkout", branch_name], run_dir, log_file)
13559
13560 pull_command = ["git", "pull"]
13561 if rebase:
13562 pull_command.append("--rebase")
13563 current_operation = f"pull:{branch_name}"
13564 _stream_command_to_console_and_log(pull_command, run_dir, log_file)
13565 current_operation = None
13566 except CommandExecutionError as exc:
13567 pull_error = exc
13568 if current_operation and current_operation.startswith("pull:"):
13569 _attempt_pull_cleanup(run_dir, rebase, log_file)
13570 finally:
13571 try:
13572 _restore_git_head(run_dir, original_head, log_file)
13573 except CommandExecutionError as exc:
13574 restore_error = exc
13575
13576 print("-" * 60)
13577 if pull_error:
13578 if restore_error:
13579 print(
13580 f"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
13581 f"Check log: {os.path.relpath(log_path)}",
13582 file=sys.stderr,
13583 )
13584 sys.exit(restore_error.returncode)
13585 print(
13586 f"[FATAL] Multi-branch pull failed. Original branch restored. "
13587 f"Check log: {os.path.relpath(log_path)}",
13588 file=sys.stderr,
13589 )
13590 sys.exit(pull_error.returncode)
13591
13592 if restore_error:
13593 print(
13594 f"[FATAL] Branch updates completed, but the original branch could not be restored. "
13595 f"Check log: {os.path.relpath(log_path)}",
13596 file=sys.stderr,
13597 )
13598 sys.exit(restore_error.returncode)
13599
13600 if skipped_branches:
13601 print(f"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
13602 print("[SUCCESS] All local tracking branches are up to date.")
13603
13604def auto_identify_run_inputs(config_dir: str):
13605 """!
13606 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
13607 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
13608 @return Value returned by `auto_identify_run_inputs()`.
13609 """
13610 run_dir = os.path.dirname(os.path.abspath(config_dir))
13611 active = load_active_run_configuration(run_dir)
13612 if active:
13613 case_path = active.get("case")
13614 monitor_path = active.get("monitor")
13615 solver_control_path = active.get("control")
13616 if all(path and os.path.isfile(path) for path in (case_path, monitor_path, solver_control_path)):
13617 return case_path, monitor_path, solver_control_path
13618 all_yml_files = glob.glob(os.path.join(config_dir, "*.yml"))
13619 case_path, monitor_path = None, None
13620 for f_path in all_yml_files:
13621 try:
13622 content = read_yaml_file(f_path)
13623 if not isinstance(content, dict):
13624 continue
13625 if 'models' in content and 'boundary_conditions' in content:
13626 case_path = f_path
13627 elif 'io' in content and 'logging' in content:
13628 monitor_path = f_path
13629 except Exception as e:
13630 print(f"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
13631 try:
13632 solver_control_path = glob.glob(os.path.join(config_dir, "*.control"))[0]
13633 except IndexError:
13634 solver_control_path = None
13635 return case_path, monitor_path, solver_control_path
13636
13637def resolve_post_source_directory(run_dir: str, monitor_cfg: dict, post_cfg: dict, strict: bool = True) -> str:
13638 """!
13639 @brief Resolve post source directory token and optionally enforce existence.
13640 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
13641 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
13642 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
13643 @param[in] strict Argument passed to `resolve_post_source_directory()`.
13644 @return Value returned by `resolve_post_source_directory()`.
13645 """
13646 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
13647 source_dir_template = get_post_source_directory_template(post_cfg)
13648 if source_dir_template == '<solver_output_dir>':
13649 resolved_source_dir = solver_output_dir_abs
13650 print(f"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
13651 else:
13652 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
13653 print(f"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
13654
13655 if strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
13656 print(
13657 f"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
13658 file=sys.stderr
13659 )
13660 sys.exit(1)
13661 if not strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
13662 print("[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
13663 return resolved_source_dir
13664
13666 script_path: str,
13667 job_name: str,
13668 cluster_cfg: dict,
13669 array_spec: str,
13670 case_index_tsv: str,
13671 stage: str,
13672 solver_exe: str,
13673 post_exe: str,
13674 stdout_path: str,
13675 stderr_path: str
13676):
13677 """!
13678 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
13679 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
13680 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
13681 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
13682 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
13683 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
13684 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
13685 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
13686 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
13687 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
13688 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
13689 @return Value returned by `render_slurm_array_stage_script()`.
13690 """
13691 effective_cluster_cfg = cluster_cfg
13692 resources = effective_cluster_cfg.get("resources", {})
13693 notifications = effective_cluster_cfg.get("notifications", {}) or {}
13694 execution = effective_cluster_cfg.get("execution", {}) or {}
13695 module_setup = execution.get("module_setup", []) or []
13696 extra_sbatch = execution.get("extra_sbatch")
13697
13698 lines = [
13699 "#!/bin/bash",
13700 f"#SBATCH --job-name={job_name}",
13701 f"#SBATCH --nodes={resources['nodes']}",
13702 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
13703 f"#SBATCH --mem={resources['mem']}",
13704 f"#SBATCH --time={resources['time']}",
13705 f"#SBATCH --output={stdout_path}",
13706 f"#SBATCH --error={stderr_path}",
13707 f"#SBATCH --account={resources['account']}",
13708 f"#SBATCH --array={array_spec}",
13709 ]
13710 partition = resources.get("partition")
13711 if partition:
13712 lines.append(f"#SBATCH --partition={partition}")
13713 mail_user = notifications.get("mail_user")
13714 mail_type = notifications.get("mail_type")
13715 if mail_user:
13716 lines.append(f"#SBATCH --mail-user={mail_user}")
13717 if mail_type:
13718 lines.append(f"#SBATCH --mail-type={mail_type}")
13719 if isinstance(extra_sbatch, dict):
13720 for key, value in extra_sbatch.items():
13721 flag = str(key)
13722 if not flag.startswith("--"):
13723 flag = f"--{flag}"
13724 if isinstance(value, bool):
13725 if value:
13726 lines.append(f"#SBATCH {flag}")
13727 elif value is not None:
13728 lines.append(f"#SBATCH {flag}={value}")
13729 elif isinstance(extra_sbatch, list):
13730 for token in extra_sbatch:
13731 lines.append(f"#SBATCH {token}")
13732
13733 lines.extend([
13734 "",
13735 "set -euo pipefail",
13736 "",
13737 f'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
13738 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
13739 'if [ -z "$LINE" ]; then',
13740 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
13741 ' exit 1',
13742 "fi",
13743 "IFS=$'\\t' read -r CASE_INDEX CASE_ID RUN_DIR CONTROL_FILE POST_RECIPE_FILE LOG_LEVEL POST_PREFIX SOLVE_DIAGNOSTIC_ARGS POST_DIAGNOSTIC_ARGS <<< \"$LINE\"",
13744 'cd "$RUN_DIR"',
13745 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
13746 ])
13747
13748 if stage == "solve":
13749 walltime_guard_exports = build_walltime_guard_exports(effective_cluster_cfg)
13750 for key, value in walltime_guard_exports.items():
13751 lines.append(f"export {key}={value}")
13752
13753 lines.append('export LOG_LEVEL="${LOG_LEVEL}"')
13754
13755 for setup_line in module_setup:
13756 lines.append(str(setup_line))
13757
13758 if stage == "solve":
13760 effective_cluster_cfg,
13761 solver_exe,
13762 ["-control_file", "$CONTROL_FILE"]
13763 )
13764 else:
13766 effective_cluster_cfg,
13767 post_exe,
13768 ["-control_file", "$CONTROL_FILE", "-postprocessing_config_file", "$POST_RECIPE_FILE"],
13769 force_num_procs=get_cluster_total_tasks(effective_cluster_cfg),
13770 )
13771
13772 # Keep shell variables unresolved inside sbatch script.
13773 def _token(tok: str) -> str:
13774 """!
13775 @brief Preserve shell-variable tokens while safely quoting literal command arguments for an sbatch script.
13776 @param[in] tok Argument passed to `_token()`.
13777 @return Value returned by `_token()`.
13778 """
13779 if tok.startswith("$"):
13780 return tok
13781 return shlex.quote(str(tok))
13782
13783 diag_var = "${SOLVE_DIAGNOSTIC_ARGS}" if stage == "solve" else "${POST_DIAGNOSTIC_ARGS}"
13784 command_text = " ".join(_token(t) for t in cmd)
13785 executable_token = _token(solver_exe if stage == "solve" else post_exe)
13786 # Diagnostic args must be executable args, not launcher args. Insert them
13787 # immediately before the executable's normal control/recipe options.
13788 if executable_token and command_text.count(executable_token) == 1:
13789 command_text = command_text.replace(f"{executable_token} ", f"{executable_token} {diag_var} ", 1)
13790 lines.append(f"exec {command_text}")
13791
13792 os.makedirs(os.path.dirname(script_path), exist_ok=True)
13793 with open(script_path, "w") as f:
13794 f.write("\n".join(lines) + "\n")
13795 os.chmod(script_path, 0o755)
13796
13797
13799 script_path: str,
13800 job_name: str,
13801 cluster_cfg: dict,
13802 study_dir: str,
13803 picurv_path: str,
13804):
13805 """!
13806 @brief Generate a single-node sbatch script that runs metrics aggregation.
13807 @param[in] script_path Path to write the sbatch script.
13808 @param[in] job_name Slurm job name.
13809 @param[in] cluster_cfg Parsed cluster YAML dictionary.
13810 @param[in] study_dir Absolute path to the study directory.
13811 @param[in] picurv_path Absolute path to the picurv script.
13812 """
13813 resources = cluster_cfg.get("resources", {})
13814 notifications = cluster_cfg.get("notifications", {}) or {}
13815 execution = cluster_cfg.get("execution", {}) or {}
13816 module_setup = execution.get("module_setup", []) or []
13817
13818 scheduler_dir = os.path.join(study_dir, "scheduler")
13819 lines = [
13820 "#!/bin/bash",
13821 f"#SBATCH --job-name={job_name}",
13822 "#SBATCH --nodes=1",
13823 "#SBATCH --ntasks-per-node=1",
13824 "#SBATCH --mem=4G",
13825 "#SBATCH --time=00:10:00",
13826 f"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
13827 f"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
13828 f"#SBATCH --account={resources['account']}",
13829 ]
13830 partition = resources.get("partition")
13831 if partition:
13832 lines.append(f"#SBATCH --partition={partition}")
13833 mail_user = notifications.get("mail_user")
13834 mail_type = notifications.get("mail_type")
13835 if mail_user:
13836 lines.append(f"#SBATCH --mail-user={mail_user}")
13837 if mail_type:
13838 lines.append(f"#SBATCH --mail-type={mail_type}")
13839
13840 lines.extend([
13841 "",
13842 "set -euo pipefail",
13843 'echo "[$(date)] Running metrics aggregation"',
13844 "",
13845 ])
13846 for setup_line in module_setup:
13847 lines.append(str(setup_line))
13848
13849 lines.append(
13850 f"exec {shlex.quote(picurv_path)} sweep --reaggregate"
13851 f" --study-dir {shlex.quote(study_dir)}"
13852 )
13853
13854 os.makedirs(os.path.dirname(script_path), exist_ok=True)
13855 with open(script_path, "w") as f:
13856 f.write("\n".join(lines) + "\n")
13857 os.chmod(script_path, 0o755)
13858
13859
13860def reduce_metric_values(values, reduction: str):
13861 """!
13862 @brief Reduce a metric series to one scalar according to the requested reducer.
13863 @param[in] values Sequence of numeric values.
13864 @param[in] reduction Reduction keyword.
13865 @return Value returned by `reduce_metric_values()`.
13866 """
13867 if not values:
13868 return None
13869 np = require_numpy()
13870 reduction = str(reduction).lower()
13871 if reduction == "mean":
13872 return float(np.mean(values))
13873 if reduction == "min":
13874 return float(np.min(values))
13875 if reduction == "max":
13876 return float(np.max(values))
13877 if reduction == "p95":
13878 return float(np.percentile(values, 95.0))
13879 return float(values[-1])
13880
13881
13882def extract_metric_from_csv(case_dir: str, spec: dict):
13883 """!
13884 @brief Extract a scalar metric from a CSV source.
13885 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
13886 @param[in] spec Argument passed to `extract_metric_from_csv()`.
13887 @return Value returned by `extract_metric_from_csv()`.
13888 """
13889 file_glob = spec.get("file_glob", "**/*_msd.csv")
13890 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
13891 if not candidates:
13892 return None
13893 csv_path = candidates[0]
13894 rows = []
13895 with open(csv_path, "r", newline="") as f:
13896 reader = csv.DictReader(f)
13897 if reader.fieldnames:
13898 for row in reader:
13899 rows.append(row)
13900 if not rows:
13901 return None
13902 column = spec.get("column")
13903 numerator_column = spec.get("numerator_column")
13904 denominator_column = spec.get("denominator_column")
13905 denominator_floor = float(spec.get("denominator_floor", 0.0) or 0.0)
13906 if not column and not numerator_column:
13907 for name in reversed(reader.fieldnames):
13908 if name and name.lower() not in {"step", "time", "timestep"}:
13909 column = name
13910 break
13911 if not column and not numerator_column:
13912 return None
13913 values = []
13914 for row in rows:
13915 try:
13916 if numerator_column:
13917 numerator = float(row[numerator_column])
13918 denominator = float(row[denominator_column])
13919 denominator = max(denominator_floor, denominator)
13920 if denominator == 0.0:
13921 continue
13922 values.append(numerator / denominator)
13923 else:
13924 values.append(float(row[column]))
13925 except Exception:
13926 continue
13927 else:
13928 return None
13929 return reduce_metric_values(values, spec.get("reduction", "last"))
13930
13931
13932def extract_metric_from_log(case_dir: str, spec: dict):
13933 """!
13934 @brief Extract a scalar metric from a log file using regex.
13935 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
13936 @param[in] spec Argument passed to `extract_metric_from_log()`.
13937 @return Value returned by `extract_metric_from_log()`.
13938 """
13939 file_glob = spec.get("file_glob", "logs/*.log")
13940 regex = spec.get("regex")
13941 if not regex:
13942 return None
13943 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
13944 if not candidates:
13945 return None
13946 pattern = re.compile(regex)
13947 values = []
13948 for path in candidates:
13949 try:
13950 with open(path, "r", encoding="utf-8", errors="replace") as f:
13951 for line in f:
13952 m = pattern.search(line)
13953 if m:
13954 try:
13955 values.append(float(m.group(1)))
13956 except Exception:
13957 pass
13958 except OSError:
13959 continue
13960 return reduce_metric_values(values, spec.get("reduction", "last"))
13961
13962
13964 """!
13965 @brief Normalize study metric definitions to a common dictionary form.
13966 @param[in] metric Argument passed to `normalize_metric_spec()`.
13967 @return Value returned by `normalize_metric_spec()`.
13968 """
13969 if isinstance(metric, str):
13970 if metric.lower() in {"msd", "msd_final"}:
13971 return {
13972 "name": "msd_final",
13973 "source": "statistics_csv",
13974 "file_glob": "**/*_msd.csv",
13975 "reduction": "last",
13976 }
13977 return {"name": metric, "source": "log_regex", "regex": metric}
13978 return dict(metric)
13979
13980def _read_previous_metric_rows(results_dir: str) -> dict:
13981 """!
13982 @brief Read the metrics table an earlier aggregation wrote, keyed by case id.
13983 @param[in] results_dir Study analysis directory holding metrics_table.csv.
13984 @return Mapping of case id to its previously recorded row, empty when absent.
13985 """
13986 path = os.path.join(results_dir, "metrics_table.csv")
13987 if not os.path.isfile(path):
13988 return {}
13989 try:
13990 with open(path, "r", encoding="utf-8", newline="") as stream:
13991 return {
13992 row["case_id"]: row for row in csv.DictReader(stream) if row.get("case_id")
13993 }
13994 except (OSError, ValueError):
13995 return {}
13996
13997
13998def aggregate_study_metrics(study_cfg: dict, cases: list, results_dir: str) -> str:
13999 """!
14000 @brief Collect metric values from generated case directories into one CSV.
14001 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
14002 @param[in] cases Argument passed to `aggregate_study_metrics()`.
14003 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
14004 @return Value returned by `aggregate_study_metrics()`.
14005 """
14006 metrics = study_cfg.get("metrics", [])
14007 if not metrics:
14008 metrics = ["msd_final"]
14009 normalized_specs = [normalize_metric_spec(m) for m in metrics]
14010
14011 # A member whose payload was archived cannot be re-measured. Its previously
14012 # aggregated values are the correct answer for it, so they are carried forward
14013 # rather than overwritten with blanks or the whole table refused.
14014 preserved = _read_previous_metric_rows(results_dir)
14015 rows = []
14016 for case in cases:
14017 row = {"case_id": case["case_id"]}
14018 flat_parameters = flatten_study_parameters(case.get("parameters", {}))
14019 for p_key, p_val in flat_parameters.items():
14020 row[p_key] = p_val
14021 if is_artifact_cold(case["run_dir"]):
14022 retained = preserved.get(case["case_id"])
14023 if retained:
14024 for spec in normalized_specs:
14025 name = spec.get("name", "metric")
14026 row[name] = retained.get(name)
14027 row["_source"] = "retained"
14028 rows.append(row)
14029 continue
14030 print(
14031 f"[WARN] {case['case_id']} is in cold storage and no previous metrics "
14032 "row was found; its values are reported as unavailable.",
14033 file=sys.stderr,
14034 )
14035 for spec in normalized_specs:
14036 row[spec.get("name", "metric")] = None
14037 rows.append(row)
14038 continue
14039 for spec in normalized_specs:
14040 name = spec.get("name", "metric")
14041 source = str(spec.get("source", "")).lower()
14042 if source in METRIC_SOURCE_KINDS[:2]:
14043 value = extract_metric_from_csv(case["run_dir"], spec)
14044 elif source in METRIC_SOURCE_KINDS[2:]:
14045 value = extract_metric_from_log(case["run_dir"], spec)
14046 else:
14047 value = None
14048
14049 normalize_key = spec.get("normalize_by_parameter")
14050 if value is not None and normalize_key:
14051 denom = flat_parameters.get(normalize_key)
14052 try:
14053 denom = float(denom)
14054 except Exception:
14055 denom = None
14056 if denom not in (None, 0.0):
14057 value = float(value) / denom
14058 else:
14059 value = None
14060
14061 row[name] = value
14062 rows.append(row)
14063
14064 if not rows:
14065 return None
14066
14067 all_keys = []
14068 seen = set()
14069 for row in rows:
14070 for k in row.keys():
14071 if k not in seen:
14072 seen.add(k)
14073 all_keys.append(k)
14074
14075 os.makedirs(results_dir, exist_ok=True)
14076 out_csv = os.path.join(results_dir, "metrics_table.csv")
14077 with open(out_csv, "w", newline="") as f:
14078 writer = csv.DictWriter(f, fieldnames=all_keys)
14079 writer.writeheader()
14080 writer.writerows(rows)
14081 print(f"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
14082 return out_csv
14083
14084_STUDY_REPORT_COLORS = (
14085 "#0072B2", "#D55E00", "#009E73", "#CC79A7",
14086 "#E69F00", "#56B4E9", "#332288", "#999999",
14087)
14088
14089
14090def _study_parameter_label(key: str) -> str:
14091 """!
14092 @brief Return a concise report label for a study parameter path.
14093 @param[in] key Dotted study parameter path.
14094 @return Report-facing axis or legend label.
14095 """
14096 exact = {
14097 "case.run_control.dt_physical": "Physical timestep, Δt",
14098 "case.models.physics.particles.count": "Particle count",
14099 "solver.operation_mode.uniform_flow.u": "Uniform-flow velocity, u",
14100 "case.grid.programmatic_settings.im": "Grid nodes in i, Nᵢ",
14101 "case.grid.programmatic_settings.jm": "Grid nodes in j, Nⱼ",
14102 "case.grid.programmatic_settings.km": "Grid nodes in k, Nₖ",
14103 }
14104 return exact.get(key, _humanize_plot_identifier(key))
14105
14106
14107def _study_metric_label(study_cfg: dict, metric: str) -> str:
14108 """!
14109 @brief Resolve an optional configured metric label or humanize its name.
14110 @param[in] study_cfg Parsed study configuration.
14111 @param[in] metric Metric column name.
14112 @return Report-facing metric label.
14113 """
14114 for raw_spec in study_cfg.get("metrics", []) or []:
14115 spec = normalize_metric_spec(raw_spec)
14116 if spec.get("name") != metric:
14117 continue
14118 label = spec.get("plot_label") or spec.get("label")
14119 units = spec.get("units")
14120 base = str(label) if label else _humanize_plot_identifier(metric)
14121 return f"{base} ({units})" if units else base
14122 return _humanize_plot_identifier(metric)
14123
14124
14125def _numeric_study_column(rows: list, key: str) -> "list | None":
14126 """!
14127 @brief Parse one complete, finite numeric study-table column.
14128 @param[in] rows Metrics-table rows.
14129 @param[in] key Column name.
14130 @return Numeric values, or None when the column is incomplete or nonnumeric.
14131 """
14132 values = []
14133 for row in rows:
14134 try:
14135 value = float(row[key])
14136 except (KeyError, TypeError, ValueError):
14137 return None
14138 if not math.isfinite(value):
14139 return None
14140 values.append(value)
14141 return values
14142
14143
14144def _infer_study_plot_axis(study_cfg: dict, rows: list) -> "dict | None":
14145 """!
14146 @brief Infer the scientifically meaningful independent variable of a study.
14147 @param[in] study_cfg Parsed study configuration.
14148 @param[in] rows Metrics-table rows.
14149 @return Axis metadata, or None when no numeric independent variable exists.
14150 """
14151 params = [key for key in get_study_parameter_keys(study_cfg) if rows and key in rows[0]]
14152 if not params or not rows:
14153 return None
14154
14155 grid_keys = (
14156 "case.grid.programmatic_settings.im",
14157 "case.grid.programmatic_settings.jm",
14158 "case.grid.programmatic_settings.km",
14159 )
14160 if study_cfg.get("study_type") == "grid_independence" and all(key in params for key in grid_keys):
14161 columns = [_numeric_study_column(rows, key) for key in grid_keys]
14162 if all(column is not None for column in columns):
14163 values = [
14164 (columns[0][index] * columns[1][index] * columns[2][index]) ** (1.0 / 3.0)
14165 for index in range(len(rows))
14166 ]
14167 return {
14168 "key": "characteristic_grid_resolution",
14169 "label": "Characteristic grid resolution, (NᵢNⱼNₖ)¹⁄³",
14170 "slug": "characteristic_grid_resolution",
14171 "values": values,
14172 "contributors": set(grid_keys),
14173 }
14174
14175 preferred = []
14176 if study_cfg.get("study_type") == "timestep_independence":
14177 preferred = [key for key in params if key.endswith(".dt_physical")]
14178 numeric = []
14179 for key in preferred + [key for key in params if key not in preferred]:
14180 values = _numeric_study_column(rows, key)
14181 if values is not None:
14182 numeric.append((key, values, len(set(values))))
14183 if not numeric:
14184 return None
14185 varied = [candidate for candidate in numeric if candidate[2] > 1]
14186 key, values, _unique = (varied or numeric)[0]
14187 return {
14188 "key": key,
14189 "label": _study_parameter_label(key),
14190 "slug": re.sub(r"[^A-Za-z0-9_.-]+", "_", key),
14191 "values": values,
14192 "contributors": {key},
14193 }
14194
14195
14196def infer_plot_x_axis(study_cfg: dict, rows: list):
14197 """!
14198 @brief Infer x-axis key/values for study plots.
14199 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
14200 @param[in] rows Argument passed to `infer_plot_x_axis()`.
14201 @return Value returned by `infer_plot_x_axis()`.
14202 """
14203 axis = _infer_study_plot_axis(study_cfg, rows)
14204 if axis is None:
14205 return None, None
14206 return axis["label"], axis["values"]
14207
14208
14210 """!
14211 @brief Format a secondary study parameter compactly for a legend.
14212 @param[in] value Parameter value.
14213 @return Compact report string.
14214 """
14215 try:
14216 number = float(value)
14217 except (TypeError, ValueError):
14218 return str(value)
14219 if number.is_integer():
14220 return f"{int(number):,}"
14221 return f"{number:g}"
14222
14223
14224def _study_plot_groups(study_cfg: dict, rows: list, axis: dict, metric: str) -> list:
14225 """!
14226 @brief Group metric points by any secondary varied study parameters.
14227 @param[in] study_cfg Parsed study configuration.
14228 @param[in] rows Metrics-table rows.
14229 @param[in] axis Inferred independent-axis metadata.
14230 @param[in] metric Metric column name.
14231 @return Labeled point groups.
14232 """
14233 param_keys = [key for key in get_study_parameter_keys(study_cfg) if rows and key in rows[0]]
14234 secondary = []
14235 for key in param_keys:
14236 if key in axis["contributors"]:
14237 continue
14238 values = {str(row.get(key)) for row in rows}
14239 # A control coupled one-to-one with x (for example total_steps paired
14240 # with dt to keep physical duration fixed, or particle count paired with
14241 # grid size to keep PPC fixed) describes the same study trajectory. It
14242 # must not split that trajectory into one-point pseudo-series.
14243 if 1 < len(values) < len(rows):
14244 secondary.append(key)
14245
14246 groups = {}
14247 for row_index, row in enumerate(rows):
14248 try:
14249 y_value = float(row[metric])
14250 except (KeyError, TypeError, ValueError):
14251 continue
14252 if not math.isfinite(y_value):
14253 continue
14254 identity = tuple(row.get(key) for key in secondary)
14255 groups.setdefault(identity, []).append([axis["values"][row_index], y_value])
14256
14257 result = []
14258 for identity, points in groups.items():
14259 points.sort(key=lambda point: point[0])
14260 label = ", ".join(
14261 f"{_study_parameter_label(key)} = {_format_study_group_value(value)}"
14262 for key, value in zip(secondary, identity)
14263 )
14264 result.append({"label": label or "Study cases", "points": points})
14265 return result
14266
14267
14268def _study_use_log_scale(values: list, semantic_hint: bool = False) -> bool:
14269 """!
14270 @brief Use log scaling only for positive data spanning a meaningful range.
14271 @param[in] values Candidate axis values.
14272 @param[in] semantic_hint Whether the quantity is conventionally read logarithmically.
14273 @return True when logarithmic scaling improves interpretation.
14274 """
14275 if not values or any(value <= 0.0 for value in values):
14276 return False
14277 ratio = max(values) / min(values)
14278 return ratio >= (20.0 if semantic_hint else 100.0)
14279
14280
14281def _study_linear_y_limits(values: list) -> "tuple[float, float] | None":
14282 """!
14283 @brief Build padded linear limits that include zero for non-negative metrics.
14284 @param[in] values Finite metric values.
14285 @return Lower and upper limits, or None for an empty sequence.
14286 """
14287 if not values:
14288 return None
14289 lower, upper = min(values), max(values)
14290 if lower >= 0.0:
14291 lower = 0.0
14292 span = upper - lower
14293 padding = max(span * 0.06, abs(upper) * 0.02, 1.0e-12)
14294 return (lower, upper + padding) if lower == 0.0 else (lower - padding, upper + padding)
14295
14296def generate_study_plots(study_cfg: dict, metrics_csv: str, plots_dir: str):
14297 """!
14298 @brief Generate metric-vs-parameter plots for completed studies.
14299 @param[in] study_cfg Argument passed to `generate_study_plots()`.
14300 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
14301 @param[in] plots_dir Argument passed to `generate_study_plots()`.
14302 @return Value returned by `generate_study_plots()`.
14303 """
14304 plotting_cfg = study_cfg.get("plotting", {}) or {}
14305 if plotting_cfg.get("enabled", True) is False:
14306 print("[INFO] Plotting disabled by study.yml.")
14307 return []
14309 if plt is None:
14310 print("[WARNING] matplotlib not available; skipping plot generation.")
14311 return []
14312 if not metrics_csv or not os.path.isfile(metrics_csv):
14313 return []
14314
14315 with open(metrics_csv, "r", newline="") as f:
14316 reader = csv.DictReader(f)
14317 rows = list(reader)
14318 if not rows:
14319 return []
14320
14321 axis = _infer_study_plot_axis(study_cfg, rows)
14322 if axis is None:
14323 print("[WARNING] Could not infer numeric x-axis for plots; skipping.")
14324 return []
14325
14326 configured_metrics = study_cfg.get("metrics", []) or ["msd_final"]
14327 metric_keys = [
14328 normalize_metric_spec(metric).get("name", "metric") for metric in configured_metrics
14329 if normalize_metric_spec(metric).get("name", "metric") in rows[0]
14330 ]
14331
14332 out_format = plotting_cfg.get("output_format", "png")
14333 os.makedirs(plots_dir, exist_ok=True)
14334 generated = []
14335 for metric in metric_keys:
14336 groups = _study_plot_groups(study_cfg, rows, axis, metric)
14337 if not groups:
14338 continue
14339 all_x = [point[0] for group in groups for point in group["points"]]
14340 all_y = [point[1] for group in groups for point in group["points"]]
14341 metric_label = _study_metric_label(study_cfg, metric)
14342 metric_hint = metric.lower()
14343 y_log = _study_use_log_scale(
14344 all_y,
14345 any(token in metric_hint for token in ("error", "residual", "drift", "msd")),
14346 )
14347 x_log = _study_use_log_scale(all_x)
14348 grid_cross_product = (
14349 study_cfg.get("study_type") == "grid_independence"
14350 and bool(study_cfg.get("parameters")) and len(axis["contributors"]) == 3
14351 and sum(
14352 len(set(float(row[key]) for row in rows)) > 1
14353 for key in axis["contributors"]
14354 ) > 1
14355 )
14356
14357 plt.figure(figsize=(9.2, 5.5), facecolor="white")
14358 for index, group in enumerate(groups):
14359 x_values = [point[0] for point in group["points"]]
14360 y_values = [point[1] for point in group["points"]]
14361 repeated_x = len(set(x_values)) != len(x_values)
14362 plt.plot(
14363 x_values, y_values,
14364 color=_STUDY_REPORT_COLORS[index % len(_STUDY_REPORT_COLORS)],
14365 marker="o", markersize=5.2, markeredgewidth=0.7,
14366 linewidth=0.0 if repeated_x or grid_cross_product else 1.8,
14367 linestyle="none" if repeated_x or grid_cross_product else "-",
14368 label=group["label"],
14369 )
14370 plt.xlabel(axis["label"], fontsize=11)
14371 plt.ylabel(metric_label, fontsize=11)
14372 study_label = {
14373 "grid_independence": "Grid-independence study",
14374 "timestep_independence": "Timestep-independence study",
14375 "sensitivity": "Sensitivity study",
14376 }.get(study_cfg.get("study_type"), "Parameter study")
14377 plt.title(f"{metric_label} vs. {axis['label']}\n{study_label}", fontsize=13, loc="left", pad=12)
14378 if x_log and hasattr(plt, "xscale"):
14379 plt.xscale("log")
14380 if y_log:
14381 plt.yscale("log")
14382 elif hasattr(plt, "ylim"):
14383 limits = _study_linear_y_limits(all_y)
14384 if limits:
14385 plt.ylim(*limits)
14386 if hasattr(plt, "tick_params"):
14387 plt.tick_params(axis="both", which="major", labelsize=9.5)
14388 plt.grid(True, alpha=0.24, which="major", linewidth=0.7)
14389 if x_log or y_log:
14390 plt.grid(True, alpha=0.08, which="minor", linewidth=0.5)
14391 if len(groups) > 1:
14392 plt.legend(
14393 loc="upper left", bbox_to_anchor=(1.01, 1.0), borderaxespad=0.0,
14394 frameon=False, title="Fixed parameters", fontsize=9, title_fontsize=9.5,
14395 )
14396 plt.tight_layout(rect=(0.0, 0.0, 0.76, 1.0))
14397 else:
14398 plt.tight_layout()
14399 safe_metric = re.sub(r"[^A-Za-z0-9_.-]+", "_", metric)
14400 out_path = os.path.join(plots_dir, f"{safe_metric}_vs_{axis['slug']}.{out_format}")
14401 plt.savefig(out_path, dpi=240, bbox_inches="tight", facecolor="white")
14402 plt.close()
14403 generated.append(out_path)
14404 if generated:
14405 print(f"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
14406 return generated
14407
14408
14409def _command_to_string(command_tokens: list) -> str:
14410 """!
14411 @brief Render a command list as a shell-safe display string.
14412 @param[in] command_tokens Argument passed to `_command_to_string()`.
14413 @return Value returned by `_command_to_string()`.
14414 """
14415 return " ".join(shlex.quote(str(tok)) for tok in command_tokens)
14416
14417
14418def _resolve_post_source_directory_preview(run_dir: str, monitor_cfg: dict, post_cfg: dict) -> str:
14419 """!
14420 @brief Resolve post source directory without side effects or stdout/stderr output.
14421 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
14422 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
14423 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
14424 @return Value returned by `_resolve_post_source_directory_preview()`.
14425 """
14426 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
14427 source_dir_template = get_post_source_directory_template(post_cfg)
14428 if source_dir_template == '<solver_output_dir>':
14429 return solver_output_dir_abs
14430 return os.path.abspath(os.path.join(run_dir, source_dir_template))
14431
14432
14433def build_run_dry_plan(args) -> dict:
14434 """!
14435 @brief Build a no-write execution plan for `run --dry-run`.
14436 @param[in] args Command-line style argument list supplied to the function.
14437 @return Value returned by `build_run_dry_plan()`.
14438 """
14439 plan = {
14440 "mode": "dry-run",
14441 "created_at": datetime.now().isoformat(),
14442 "warnings": [],
14443 "inputs": {},
14444 "stages": {},
14445 "artifacts": [],
14446 }
14447
14448 if args.dry_run and args.no_submit:
14449 plan["warnings"].append("--dry-run takes precedence over --no-submit; no files will be written.")
14450
14451 cluster_mode = bool(getattr(args, "cluster", None))
14452 cluster_cfg = None
14453 cluster_path = None
14454 solver_num_procs_effective = args.num_procs
14455 post_num_procs_effective = args.num_procs
14456 run_id = None
14457 run_dir = None
14458 solver_control_path = None
14459 loaded_case_cfg = None
14460 loaded_monitor_cfg = None
14461 resolved_restart_source_dir = None
14462
14463 if cluster_mode:
14464 cluster_path = os.path.abspath(args.cluster)
14465 cluster_cfg = read_yaml_file(cluster_path)
14466 validate_cluster_config(cluster_cfg, cluster_path)
14467 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
14468 if args.scheduler and args.scheduler.lower() != scheduler_type:
14470 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14471 key="scheduler.type",
14472 file_path=cluster_path,
14473 message=f"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
14474 )
14475 sys.exit(1)
14476 if scheduler_type != "slurm":
14478 ERROR_CODE_CFG_INVALID_VALUE,
14479 key="scheduler.type",
14480 file_path=cluster_path,
14481 message=f"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.",
14482 )
14483 sys.exit(1)
14484 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
14485 if (args.solve or args.post_process) and args.num_procs not in (1, cluster_tasks):
14487 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14488 key="resources.ntasks_per_node",
14489 file_path=cluster_path,
14490 message=(
14491 "--num-procs must be 1 (auto) or "
14492 f"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
14493 ),
14494 )
14495 sys.exit(1)
14496 if args.solve:
14497 solver_num_procs_effective = cluster_tasks
14498 if args.post_process:
14499 post_num_procs_effective = cluster_tasks
14500 plan["launch_mode"] = "slurm"
14501 plan["inputs"]["cluster"] = cluster_path
14502 else:
14503 if getattr(args, "scheduler", None):
14504 fail_cli_usage("--scheduler requires --cluster in this version.")
14505 plan["launch_mode"] = "local"
14506
14507 # --- Guard: restart flags without --solve ---
14508 if not args.solve:
14509 if getattr(args, 'restart_from', None):
14510 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
14511 if getattr(args, 'continue_run', False) and not args.post_process:
14512 print("[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
14513
14514 if args.solve:
14515 case_path = os.path.abspath(args.case)
14516 workspace_root = find_workspace_root(case_path, args.solver, args.monitor, os.getcwd())
14517 if workspace_root:
14518 enforce_workspace_version(workspace_root)
14519 enforce_reproducibility_policy(workspace_root)
14520 solver_path = os.path.abspath(args.solver)
14521 monitor_path = os.path.abspath(args.monitor)
14522 loaded_case_cfg = read_yaml_file(case_path)
14523 solver_cfg = read_yaml_file(solver_path)
14524 loaded_monitor_cfg = read_yaml_file(monitor_path)
14525 validate_simulation_configs(loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path)
14526
14527 continue_mode = getattr(args, 'continue_run', False)
14528
14529 if continue_mode:
14530 # --continue reuses existing run directory
14531 if not args.run_dir:
14532 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
14533 run_dir = os.path.abspath(args.run_dir)
14534 if not os.path.isdir(run_dir):
14536 ERROR_CODE_CFG_FILE_NOT_FOUND,
14537 key="run-dir",
14538 file_path=run_dir,
14539 message="Specified run directory not found.",
14540 )
14541 sys.exit(1)
14542 run_id = os.path.basename(run_dir)
14543 else:
14544 runs_root = workspace_artifact_root(workspace_root, "runs")
14545 run_id = allocate_generated_run_id(runs_root, loaded_case_cfg, case_path)
14546 run_dir = os.path.join(runs_root, run_id)
14547
14548 try:
14549 resolved_restart_source_dir, is_continue, planned_lineage = resolve_restart_source(
14550 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir,
14551 materialize=False,
14552 )
14553 plan["lineage"] = planned_lineage or {"relationship": "root"}
14554 except ValueError as e:
14556 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14557 key="restart",
14558 file_path=case_path,
14559 message=str(e),
14560 )
14561 sys.exit(1)
14562
14563 config_dir = os.path.join(run_dir, "config")
14564 scheduler_dir = os.path.join(run_dir, "scheduler")
14565 # Report the directories the run will ACTUALLY use, not the defaults. A plan
14566 # that hides a configured path cannot warn about where output really lands -
14567 # and the fresh-run log directory is deleted recursively by the C runtime.
14568 _plan_dirs = {}
14569 logs_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"])
14570 planned_output_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
14571 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
14572 profile_path = os.path.join(config_dir, "profile.run")
14573 profiling_preview = resolve_profiling_config(loaded_monitor_cfg)
14574
14575 plan["run_id_preview"] = run_id
14576 plan["run_dir_preview"] = run_dir
14577 # A plan that omits what a real run would refuse is not a plan. The run
14578 # directory does not exist yet, but `realpath` still normalizes it, so the
14579 # run-root and ancestor verdicts are decidable here.
14580 _authorized, _ = resolve_unsafe_paths_override(_plan_dirs, "monitor.yml")
14581 plan.setdefault("blocking", [])
14582 # A run resumed into a directory that grew an unrouted peer would be refused,
14583 # so the plan has to say so rather than promising a launch that will not happen.
14584 structure_errors, structure_warnings = validate_run_directory_structure(run_dir)
14585 plan["blocking"].extend(structure_errors)
14586 plan["warnings"].extend(structure_warnings)
14587 for _key, _verdict, _message in classify_physical_containment(
14588 run_dir, effective_run_directories(_plan_dirs)):
14589 if _authorized and _verdict in WAIVABLE_PHYSICAL_VERDICTS:
14590 plan["warnings"].append(f"{_message} Allowed only because "
14591 f"'allow_unsafe_paths: true' is set.")
14592 else:
14593 plan["blocking"].append(_message)
14594 plan["inputs"].update({"case": case_path, "solver": solver_path, "monitor": monitor_path})
14595 asset_plan = plan_run_assets(loaded_case_cfg, case_path)
14596 plan["asset_actions"] = [
14597 {"kind": item["kind"], "provider": item["provider"], "action": item["action"]}
14598 for item in asset_plan["actions"]
14599 ]
14600 runtime_kinds = {
14601 item["kind"] for item in asset_plan["actions"]
14602 if item["execution"] == "runtime-c"
14603 }
14604 for item in asset_plan["actions"]:
14605 blocked_dependencies = runtime_kinds.intersection(item.get("dependencies", []))
14606 if item["execution"] == "precomputable" and blocked_dependencies:
14607 plan["blocking"].append(
14608 f"{item['kind']}={item['provider']} requires a file-backed "
14609 f"{', '.join(sorted(blocked_dependencies))}, but that dependency is generated "
14610 "only inside the simulator. Select a precomputable provider for the dependency."
14611 )
14612 plan["artifacts"].extend(
14613 [
14614 run_dir,
14615 *(os.path.join(run_dir, *relative.split("/"))
14616 for relative in RUN_DIRECTORY_LAYOUT),
14617 os.path.join(config_dir, "case.yml"),
14618 os.path.join(config_dir, "solver.yml"),
14619 os.path.join(config_dir, "monitor.yml"),
14620 os.path.join(config_dir, "active.json"),
14621 os.path.join(run_dir, CANONICAL_RUN_PATHS["inputs"], "assets.lock.yml"),
14622 solver_control_path,
14623 os.path.join(run_dir, "manifest.json"),
14624 ]
14625 )
14626 add_planned_grid_artifacts(plan, loaded_case_cfg, run_dir)
14627 add_planned_profile_artifacts(plan, loaded_case_cfg, run_dir)
14628 add_planned_initial_condition_artifacts(plan, loaded_case_cfg, solver_cfg, run_dir)
14629 if has_explicit_monitor_whitelist(loaded_monitor_cfg):
14630 plan["artifacts"].append(os.path.join(config_dir, "whitelist.run"))
14631 if profiling_preview["mode"] == "selected":
14632 plan["artifacts"].append(profile_path)
14633 solve_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "Solver")
14634 plan["artifacts"].extend(solve_diagnostics["artifacts"])
14635 if cluster_mode:
14636 plan["artifacts"].append(os.path.join(config_dir, "cluster.yml"))
14637 plan["artifacts"].append(os.path.join(scheduler_dir, "submission.json"))
14638
14639 solver_exe = resolve_runtime_executable("simulator")
14640 solver_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "Solver") + ["-control_file", solver_control_path]
14641 if cluster_mode:
14642 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
14643 solver_cmd = build_cluster_launch_command(
14644 cluster_cfg,
14645 solver_exe,
14646 solver_args,
14647 config_search_anchor=case_path,
14648 extra_search_anchors=[cluster_path],
14649 )
14650 plan["artifacts"].append(solver_script)
14651 plan["stages"]["solve"] = {
14652 "mode": "slurm",
14653 "script": solver_script,
14654 "num_procs_effective": solver_num_procs_effective,
14655 "launch_command": solver_cmd,
14656 "launch_command_string": _command_to_string(solver_cmd),
14657 }
14658 else:
14659 solver_cmd = build_local_launch_command(
14660 solver_exe,
14661 solver_args,
14662 solver_num_procs_effective,
14663 config_search_anchor=case_path,
14664 )
14665 solver_stream_log = os.path.join(scheduler_dir, f"{run_id}_solver.log")
14666 plan["artifacts"].append(solver_stream_log)
14667 plan["stages"]["solve"] = {
14668 "mode": "local",
14669 "num_procs_effective": solver_num_procs_effective,
14670 "stream_log": solver_stream_log,
14671 "launch_command": solver_cmd,
14672 "launch_command_string": _command_to_string(solver_cmd),
14673 }
14674 if resolved_restart_source_dir:
14675 plan["stages"]["solve"]["restart_source_directory"] = resolved_restart_source_dir
14676 if is_continue:
14677 plan["stages"]["solve"]["continue_mode"] = True
14678
14679 if args.post_process:
14680 post_path = os.path.abspath(args.post)
14681 plan["inputs"]["post"] = post_path
14682 post_cfg = read_yaml_file(post_path)
14683 validate_post_config(post_cfg, post_path, loaded_monitor_cfg, loaded_case_cfg)
14684
14685 if args.run_dir:
14686 run_dir = os.path.abspath(args.run_dir)
14687 if not os.path.isdir(run_dir):
14689 ERROR_CODE_CFG_FILE_NOT_FOUND,
14690 key="run-dir",
14691 file_path=run_dir,
14692 message="Specified run directory not found.",
14693 )
14694 sys.exit(1)
14695 run_id = os.path.basename(run_dir)
14696 elif not args.solve:
14697 fail_cli_usage("--post-process requires --run-dir when not used with --solve.")
14698
14699 if args.run_dir:
14700 config_dir = os.path.join(run_dir, "config")
14701 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
14702 if not all([case_path, monitor_path, solver_control_path]):
14704 ERROR_CODE_CFG_MISSING_KEY,
14705 key="run_dir.config",
14706 file_path=config_dir,
14707 message=(
14708 "Could not auto-identify required run inputs "
14709 "(case.yml/monitor.yml/*.control) in run config directory."
14710 ),
14711 )
14712 sys.exit(1)
14713 loaded_case_cfg = read_yaml_file(case_path)
14714 loaded_monitor_cfg = read_yaml_file(monitor_path)
14715 else:
14716 config_dir = os.path.join(run_dir, "config")
14717 case_path = os.path.join(config_dir, "case.yml")
14718 monitor_path = os.path.join(config_dir, "monitor.yml")
14719 if solver_control_path is None:
14720 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
14721
14722 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
14723 allow_source_frontier_scan = not args.solve
14724 post_plan = build_post_execution_plan(
14725 run_dir,
14726 run_id,
14727 loaded_case_cfg,
14728 loaded_monitor_cfg,
14729 post_cfg,
14730 continue_requested=getattr(args, 'continue_run', False),
14731 allow_source_frontier_scan=allow_source_frontier_scan,
14732 )
14733
14734 post_recipe_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.run")
14735 output_dir_rel = post_cfg.get("io", {}).get("output_directory")
14736 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix")
14737 if not output_prefix:
14739 ERROR_CODE_CFG_MISSING_KEY,
14740 key="io.output_filename_prefix",
14741 file_path=post_path,
14742 message="Missing required post output filename prefix.",
14743 )
14744 sys.exit(1)
14745 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
14746 statistics_output_paths = get_post_statistics_output_artifacts(post_cfg, run_dir, loaded_monitor_cfg)
14747 post_exe = resolve_runtime_executable("postprocessor")
14748 post_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "PostProcessor")
14749 plan["artifacts"].extend(post_diagnostics["artifacts"])
14750 post_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "PostProcessor") + [
14751 "-control_file",
14752 solver_control_path,
14753 "-postprocessing_config_file",
14754 post_recipe_path,
14755 ]
14756 plan["artifacts"].extend([
14757 post_recipe_path,
14758 output_dir_abs,
14759 post_plan["resume_state_path"],
14760 post_plan["lock_paths"]["wrapper_path"],
14761 post_plan["lock_paths"]["lock_file"],
14762 post_plan["lock_paths"]["metadata_file"],
14763 ])
14764 plan["artifacts"].extend(statistics_output_paths)
14765
14766 stage_meta = {
14767 "source_data_directory": post_plan["source_data_directory"],
14768 "requested_start_step": post_plan["requested_start_step"],
14769 "requested_end_step": post_plan["requested_end_step"],
14770 "step_interval": post_plan["step_interval"],
14771 "resume_applied": bool(post_plan["continue_requested"] and post_plan["resume_recipe_match"]),
14772 "resume_recipe_match": post_plan["resume_recipe_match"],
14773 "resume_bootstrapped": post_plan["resume_bootstrapped"],
14774 "resume_match_source": post_plan["resume_match_source"],
14775 "completed_frontier_step": post_plan["completed_frontier_step"],
14776 "source_frontier_step": post_plan["source_frontier_step"],
14777 "source_frontier_diagnostic": post_plan["source_frontier_diagnostic"],
14778 "source_frontier_deferred": post_plan["source_frontier_deferred"],
14779 "effective_start_step": post_plan["effective_start_step"],
14780 "effective_end_step": post_plan["effective_end_step"],
14781 "skip_reason": post_plan["skip_reason"],
14782 "post_skipped_as_complete": post_plan["skip_reason"] == "already-complete-window",
14783 "recipe_fingerprint": post_plan["recipe_fingerprint"],
14784 "num_procs_effective": post_num_procs_effective,
14785 }
14786
14787 if post_plan["skip_reason"] is None:
14788 if cluster_mode:
14789 scheduler_dir = os.path.join(run_dir, "scheduler")
14790 post_script = os.path.join(scheduler_dir, "post.sbatch")
14791 post_cluster_cfg = cluster_cfg
14792 raw_post_cmd = build_cluster_launch_command(
14793 post_cluster_cfg,
14794 post_exe,
14795 post_args,
14796 config_search_anchor=case_path,
14797 extra_search_anchors=[cluster_path],
14798 force_num_procs=post_num_procs_effective,
14799 )
14800 post_cmd, _ = build_post_locked_command(
14801 run_dir,
14802 post_plan["recipe_fingerprint"],
14803 raw_post_cmd,
14804 create_wrapper=False,
14805 )
14806 plan["artifacts"].append(post_script)
14807 stage_meta.update({
14808 "mode": "slurm",
14809 "script": post_script,
14810 "launch_command": post_cmd,
14811 "launch_command_string": _command_to_string(post_cmd),
14812 })
14813 else:
14814 raw_post_cmd = build_local_launch_command(
14815 post_exe,
14816 post_args,
14817 post_num_procs_effective,
14818 config_search_anchor=case_path,
14819 allow_single_rank_launcher_override=True,
14820 force_num_procs=post_num_procs_effective,
14821 )
14822 post_cmd, _ = build_post_locked_command(
14823 run_dir,
14824 post_plan["recipe_fingerprint"],
14825 raw_post_cmd,
14826 create_wrapper=False,
14827 )
14828 post_stream_log = os.path.join(run_dir, "scheduler", f"{run_id}_{output_prefix}.log")
14829 plan["artifacts"].append(post_stream_log)
14830 stage_meta.update({
14831 "mode": "local",
14832 "stream_log": post_stream_log,
14833 "launch_command": post_cmd,
14834 "launch_command_string": _command_to_string(post_cmd),
14835 })
14836 else:
14837 stage_meta.update({
14838 "mode": "slurm" if cluster_mode else "local",
14839 "launch_command": [],
14840 "launch_command_string": "",
14841 })
14842
14843 plan["stages"]["post-process"] = stage_meta
14844
14845 # Preserve insertion order while removing duplicates.
14846 deduped = []
14847 seen = set()
14848 for item in plan["artifacts"]:
14849 if item not in seen:
14850 seen.add(item)
14851 deduped.append(item)
14852 plan["artifacts"] = deduped
14853 if run_id and "run_id_preview" not in plan:
14854 plan["run_id_preview"] = run_id
14855 if run_dir and "run_dir_preview" not in plan:
14856 plan["run_dir_preview"] = run_dir
14857 plan["solver_num_procs_effective"] = solver_num_procs_effective
14858 plan["post_num_procs_effective"] = post_num_procs_effective
14859 plan["num_procs_effective"] = solver_num_procs_effective
14860 return plan
14861
14862
14863def add_planned_grid_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
14864 """!
14865 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
14866 @param[in,out] plan Dry-run plan to update.
14867 @param[in] case_cfg Parsed case configuration.
14868 @param[in] run_dir Preview run directory for relative artifact resolution.
14869 """
14870 grid_cfg = case_cfg.get("grid", {})
14871 if not isinstance(grid_cfg, dict):
14872 return
14873
14874 mode = grid_cfg.get("mode")
14875 grid_dir = os.path.join(run_dir, "inputs", "grid")
14876
14877 if mode == "file":
14878 plan["artifacts"].append(os.path.join(grid_dir, "grid.run"))
14879 elif mode == "grid_gen":
14880 generator = grid_cfg.get("generator", {})
14881 if not isinstance(generator, dict):
14882 return
14883 plan["artifacts"].extend([
14884 os.path.join(grid_dir, "grid.run"),
14885 os.path.join(grid_dir, "grid.generated.picgrid"),
14886 os.path.join(run_dir, "output", "analysis", "metrics", "grid.info"),
14887 os.path.join(run_dir, "output", "visualization", "precompute", "grid.vts"),
14888 ])
14889
14890def add_planned_profile_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
14891 """!
14892 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
14893 @param[in,out] plan Dry-run plan to update.
14894 @param[in] case_cfg Parsed case configuration.
14895 @param[in] run_dir Preview run directory.
14896 """
14897 try:
14898 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
14899 except ValueError:
14900 return
14901 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
14902 has_generated = False
14903 for block_idx, block in enumerate(prepared_blocks):
14904 for bc in block:
14905 if bc.get("handler") != "prescribed_flow":
14906 continue
14907 source = (bc.get("params") or {}).get("source", {})
14908 if source.get("type") not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
14909 continue
14910 has_generated = True
14911 face_token = _face_artifact_token(bc["face"])
14912 suffix = "generated" if source.get("type") == "generated" else "sliced"
14913 generated_path = os.path.join(
14914 profile_dir, f"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice"
14915 )
14916 staged_path = os.path.join(profile_dir, f"inlet_profile_block{block_idx}_{face_token}.picslice")
14917 plan["artifacts"].append(generated_path)
14918 plan["artifacts"].append(staged_path)
14919 if has_generated:
14920 plan["artifacts"].append(os.path.join(profile_dir, "profile.info"))
14921
14922def add_planned_initial_condition_artifacts(plan: dict, case_cfg: dict, solver_cfg: dict, run_dir: str) -> None:
14923 """!
14924 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
14925 @param[in,out] plan Dry-run plan receiving artifact paths.
14926 @param[in] case_cfg Parsed case configuration.
14927 @param[in] solver_cfg Parsed solver configuration.
14928 @param[in] run_dir Planned run directory.
14929 """
14931 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
14932 )
14933 start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
14934 if source != "solve" or start_step != 0:
14935 return
14936 try:
14937 fluid_scaling = resolve_fluid_scaling(case_cfg)
14939 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
14941 U_ref=fluid_scaling["velocity_ref"],
14942 provider_context={"kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]},
14943 )
14944 except (KeyError, ValueError):
14945 return
14946 if resolved["kind"] != "file" and not is_generated_ic_provider(resolved):
14947 return
14948 initial_dir = os.path.join(run_dir, "inputs", "initial_condition")
14949 plan["artifacts"].append(
14950 os.path.join(initial_dir, f"{resolved['field_name']}00000_0.dat")
14951 )
14952 if is_generated_ic_provider(resolved):
14953 if (case_cfg.get("grid", {}) or {}).get("mode") == "programmatic_c":
14954 plan["artifacts"].append(os.path.join(run_dir, "inputs", "grid", "grid.run"))
14955 plan["artifacts"].append(os.path.join(initial_dir, "initial_condition.generated.dat"))
14956 if resolved["kind"] == "spectral_random_velocity":
14957 plan["artifacts"].extend([
14958 os.path.join(run_dir, "output", "analysis", "metrics", "initial_condition_summary.json"),
14959 os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH),
14960 ])
14961
14962
14963def render_run_dry_plan(plan: dict, output_format: str = "text"):
14964 """!
14965 @brief Render dry-run plan in human or JSON format.
14966 @param[in] plan Dry-run plan produced by build_run_dry_plan().
14967 @param[in] output_format Either "text" or "json".
14968 @return 1 when the plan carries blocking findings, 0 otherwise.
14969 """
14970 if output_format == "json":
14971 print(json.dumps(plan, indent=2, sort_keys=True))
14972 return 1 if plan.get("blocking") else 0
14973
14974 for message in plan.get("warnings", []):
14975 print(f"[WARN] {message}", file=sys.stderr)
14976 if plan.get("blocking"):
14977 print("[FATAL] This configuration would be refused. The plan below is what the "
14978 "run WOULD do; it will not get that far:", file=sys.stderr)
14979 for message in plan["blocking"]:
14980 print(f" {message}", file=sys.stderr)
14981
14982 print("\n" + "=" * 60)
14983 print(" DRY-RUN PLAN")
14984 print("=" * 60)
14985 print(f" Launch mode : {plan.get('launch_mode')}")
14986 print(f" Created at : {plan.get('created_at')}")
14987 if plan.get("run_id_preview"):
14988 print(f" Run ID preview : {plan.get('run_id_preview')}")
14989 if plan.get("run_dir_preview"):
14990 print(f" Run dir preview: {plan.get('run_dir_preview')}")
14991 lineage = plan.get("lineage") or {}
14992 if lineage.get("relationship") == "branch":
14993 print(f" Branched from : {lineage.get('parent_run_id')} "
14994 f"@ step {lineage.get('checkpoint_step')} "
14995 f"(statistics: {lineage.get('statistics_state')})")
14996 print(f" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
14997 print(f" Post MPI procs : {plan.get('post_num_procs_effective')}")
14998 if plan.get("warnings"):
14999 print(" Warnings :")
15000 for warning in plan["warnings"]:
15001 print(f" - {warning}")
15002
15003 if plan.get("inputs"):
15004 print("\n Inputs:")
15005 for key, value in plan["inputs"].items():
15006 print(f" - {key}: {value}")
15007
15008 if plan.get("stages"):
15009 print("\n Planned stage commands:")
15010 for stage, details in plan["stages"].items():
15011 print(f" - {stage} ({details.get('mode')}):")
15012 if details.get('skip_reason'):
15013 print(f" skipped: {details.get('skip_reason')}")
15014 else:
15015 print(f" {details.get('launch_command_string')}")
15016
15017 if plan.get("asset_actions"):
15018 print("\n Asset resolution:")
15019 for item in plan["asset_actions"]:
15020 print(f" - {item['kind']}: {item['action']} ({item['provider']})")
15021
15022 diagnostics_artifacts = [item for item in plan.get("artifacts", []) if "PETSc_" in os.path.basename(str(item)) or os.path.basename(str(item)) == "Runtime_Memory.log"]
15023 if diagnostics_artifacts:
15024 print("\n Diagnostics artifacts:")
15025 for artifact in diagnostics_artifacts:
15026 print(f" - {artifact}")
15027
15028 print("\n Planned artifacts (no files created in dry-run):")
15029 for artifact in plan.get("artifacts", []):
15030 print(f" - {artifact}")
15031 print("=" * 60)
15032
15033
15035 """!
15036 @brief Implements `picurv validate` without launching solver/post workflows.
15037 @param[in] args Command-line style argument list supplied to the function.
15038 """
15039 checked = []
15040 solver_group_selected = any([args.case, args.solver, args.monitor])
15041 any_group_selected = solver_group_selected or any([args.post, args.cluster, args.study])
15042 case_path = None
15043 cluster_path = None
15044
15045 if not any_group_selected:
15047 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
15048 hint="Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
15049 )
15050
15051 if solver_group_selected and not all([args.case, args.solver, args.monitor]):
15052 fail_cli_usage("When solver validation is requested, --case, --solver, and --monitor are all required.")
15053
15054 # --- Guard: restart flags without solver group ---
15055 restart_from = getattr(args, 'restart_from', None)
15056 continue_run = getattr(args, 'continue_run', False)
15057 run_dir_val = getattr(args, 'run_dir', None)
15058 if not solver_group_selected:
15059 if restart_from:
15060 print("[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
15061 if continue_run and not args.post:
15062 print("[WARNING] --continue has no effect without solver configs or --post and will be ignored.", file=sys.stderr)
15063 if continue_run and not run_dir_val:
15064 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
15065
15066 monitor_cfg = None
15067 case_cfg = None
15068 if solver_group_selected:
15069 case_path = os.path.abspath(args.case)
15070 solver_path = os.path.abspath(args.solver)
15071 monitor_path = os.path.abspath(args.monitor)
15072 case_cfg = read_yaml_file(case_path)
15073 solver_cfg = read_yaml_file(solver_path)
15074 monitor_cfg = read_yaml_file(monitor_path)
15075 validate_simulation_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
15076 checked.extend([case_path, solver_path, monitor_path])
15077
15078 # Validate restart flags if provided
15079 if restart_from or continue_run:
15080 target_run_dir = os.path.abspath(run_dir_val) if run_dir_val else os.path.abspath("runs/_validate_dummy")
15081 try:
15082 resolve_restart_source(args, case_cfg, solver_cfg, monitor_cfg, target_run_dir)
15083 print("[SUCCESS] Restart source validation passed.")
15084 except ValueError as e:
15085 print(f"[ERROR] Restart validation failed: {e}", file=sys.stderr)
15086 sys.exit(1)
15087
15088 post_cfg = None
15089 if args.post:
15090 post_path = os.path.abspath(args.post)
15091 post_cfg = read_yaml_file(post_path)
15092 validate_post_config(post_cfg, post_path, monitor_cfg, case_cfg)
15093 checked.append(post_path)
15094
15095 cluster_cfg = None
15096 if args.cluster:
15097 cluster_path = os.path.abspath(args.cluster)
15098 cluster_cfg = read_yaml_file(cluster_path)
15099 validate_cluster_config(cluster_cfg, cluster_path)
15100 checked.append(cluster_path)
15101
15102 try:
15103 runtime_execution_path, _ = load_runtime_execution_config(
15104 case_path,
15105 extra_search_anchors=[cluster_path] if cluster_path else None,
15106 )
15107 except ValueError as exc:
15109 ERROR_CODE_CFG_INVALID_VALUE,
15110 key="runtime_execution",
15111 file_path=case_path or cluster_path or os.getcwd(),
15112 message=str(exc),
15113 )
15114 sys.exit(1)
15115 if runtime_execution_path:
15116 checked.append(runtime_execution_path)
15117
15118 study_cfg = None
15119 if args.study:
15120 study_path = os.path.abspath(args.study)
15121 study_cfg = read_yaml_file(study_path)
15122 validate_study_config(study_cfg, study_path)
15123 checked.append(study_path)
15124
15125 if post_cfg is not None and run_dir_val:
15126 post_path = os.path.abspath(args.post)
15127 validate_run_dir = os.path.abspath(run_dir_val)
15128 if os.path.isdir(validate_run_dir):
15129 monitor_for_post = monitor_cfg if solver_group_selected else None
15130 if monitor_for_post is None:
15131 config_dir_candidate = os.path.join(validate_run_dir, "config")
15132 monitor_candidate = os.path.join(config_dir_candidate, "monitor.yml")
15133 if os.path.isfile(monitor_candidate):
15134 monitor_for_post = read_yaml_file(monitor_candidate)
15135 if monitor_for_post is not None:
15136 resolved_source = _resolve_post_source_directory_preview(validate_run_dir, monitor_for_post, post_cfg)
15137 if os.path.isdir(resolved_source) and os.listdir(resolved_source):
15138 print(f"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
15139 else:
15140 print(f"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
15141
15142 if args.strict and post_cfg is not None:
15143 post_path = os.path.abspath(args.post)
15144 source_dir = post_cfg.get("source_data", {}).get("directory")
15145 if source_dir and source_dir != "<solver_output_dir>":
15146 resolved = resolve_path(post_path, source_dir)
15147 if not os.path.isdir(resolved):
15149 ERROR_CODE_CFG_FILE_NOT_FOUND,
15150 key="source_data.directory",
15151 file_path=post_path,
15152 message=f"strict mode: source_data.directory resolves to missing directory '{resolved}'.",
15153 )
15154 sys.exit(1)
15155
15156 if args.strict and study_cfg is not None:
15157 study_path = os.path.abspath(args.study)
15158 base_cfgs = study_cfg.get("base_configs", {})
15159 if isinstance(base_cfgs, dict):
15160 base_case_path = resolve_path(study_path, base_cfgs.get("case"))
15161 base_solver_path = resolve_path(study_path, base_cfgs.get("solver"))
15162 base_monitor_path = resolve_path(study_path, base_cfgs.get("monitor"))
15163 base_post_path = resolve_path(study_path, base_cfgs.get("post"))
15164 if all([base_case_path, base_solver_path, base_monitor_path]):
15166 read_yaml_file(base_case_path),
15167 read_yaml_file(base_solver_path),
15168 read_yaml_file(base_monitor_path),
15169 base_case_path,
15170 base_solver_path,
15171 base_monitor_path,
15172 )
15173 if base_post_path:
15174 validate_post_config(read_yaml_file(base_post_path), base_post_path,
15175 read_yaml_file(base_monitor_path) if base_monitor_path else None,
15176 read_yaml_file(base_case_path) if base_case_path else None)
15177
15178 print(f"[SUCCESS] Validation completed for {len(checked)} file(s).")
15179 for path in checked:
15180 print(f" - {path}")
15181
15182ASSET_KIND_DIRECTORIES = {
15183 "grid": "grids",
15184 "initial-condition": "initial_conditions",
15185 "inlet-profiles": "inlet_profiles",
15186}
15187
15188
15189def _asset_file_sha256(path: str) -> str:
15190 """!
15191 @brief Hash an asset source or payload without loading it into memory.
15192 @param[in] path File to hash.
15193 @return Lowercase SHA-256 digest.
15194 """
15195 digest = hashlib.sha256()
15196 with open(path, "rb") as stream:
15197 for block in iter(lambda: stream.read(8 * 1024 * 1024), b""):
15198 digest.update(block)
15199 return digest.hexdigest()
15200
15201
15202def _stable_mapping_sha256(payload) -> str:
15203 """!
15204 @brief Hash a JSON-compatible value with deterministic serialization.
15205 @param[in] payload Value to hash.
15206 @return Lowercase SHA-256 digest.
15207 """
15208 encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
15209 return hashlib.sha256(encoded).hexdigest()
15210
15211
15212def _provider_source_fingerprints(value, case_path: str, key: str = "") -> dict:
15213 """!
15214 @brief Hash every existing file explicitly referenced by an asset provider.
15215 @param[in] value Provider subtree to inspect.
15216 @param[in] case_path Owning case configuration path.
15217 @param[in] key Current dotted key for diagnostics.
15218 @return Mapping of dotted path identity to path/content fingerprint.
15219 """
15220 result = {}
15221 if isinstance(value, dict):
15222 for child_key, child in value.items():
15223 dotted = f"{key}.{child_key}" if key else str(child_key)
15224 result.update(_provider_source_fingerprints(child, case_path, dotted))
15225 return result
15226 if isinstance(value, list):
15227 for index, child in enumerate(value):
15228 result.update(_provider_source_fingerprints(child, case_path, f"{key}[{index}]"))
15229 return result
15230 basename = key.rsplit(".", 1)[-1]
15231 if basename not in _ASSET_SOURCE_REFERENCE_KEYS or not isinstance(value, str) or not value.strip():
15232 return result
15233 try:
15234 resolved = resolve_workspace_path(case_path, value)
15235 except ValueError:
15236 return result
15237 if os.path.isfile(resolved):
15238 workspace_root = find_workspace_root(case_path)
15239 display = (
15240 os.path.relpath(resolved, workspace_root).replace(os.sep, "/")
15241 if workspace_root and os.path.commonpath([workspace_root, resolved]) == workspace_root
15242 else resolved
15243 )
15244 result[key] = {"path": display, "sha256": _asset_file_sha256(resolved)}
15245 return result
15246
15247
15248def build_case_asset_graph(case_cfg: dict, case_path: str) -> dict:
15249 """!
15250 @brief Classify case inputs into precomputable or simulator-runtime providers.
15251 @param[in] case_cfg Parsed case configuration.
15252 @param[in] case_path Owning case YAML path.
15253 @return Provider graph with stable identities and explicit dependencies.
15254 """
15255 providers = []
15256 grid_cfg = copy.deepcopy(case_cfg.get("grid", {}) or {})
15257 grid_mode = str(grid_cfg.get("mode", "")).strip().lower()
15258 if isinstance(grid_cfg.get("generator"), dict):
15259 grid_cfg["generator"].pop("output_file", None)
15260 grid_cfg["generator"].pop("vts_file", None)
15261 grid_cfg["generator"].pop("stats_file", None)
15262 grid_provider = {
15263 "kind": "grid",
15264 "provider": grid_mode or "missing",
15265 "execution": "runtime-c" if grid_mode == "programmatic_c" else "precomputable",
15266 "dependencies": [],
15267 "spec": grid_cfg,
15268 }
15269 providers.append(grid_provider)
15270
15271 initial = copy.deepcopy(
15272 ((case_cfg.get("properties") or {}).get("initial_conditions") or {})
15273 )
15274 initial_mode = str(initial.get("mode", "generated")).strip().lower()
15275 initial_generator = str(initial.get("generator", "constant")).strip().lower()
15276 for key in ("output_file", "summary_json", "spectrum_csv"):
15277 initial.pop(key, None)
15278 if isinstance(initial.get("params"), dict):
15279 initial["params"].pop(key, None)
15280 generated_python = (
15281 initial_mode == "generated"
15282 and initial_generator in _PYTHON_INITIAL_CONDITION_PROVIDERS
15283 )
15284 initial_provider = {
15285 "kind": "initial-condition",
15286 "provider": "file" if initial_mode == "file" else initial_generator,
15287 "execution": "precomputable" if initial_mode == "file" or generated_python else "runtime-c",
15288 "dependencies": ["grid"] if generated_python else [],
15289 "spec": initial,
15290 }
15291 providers.append(initial_provider)
15292
15293 inlet_specs = []
15294 raw_blocks = case_cfg.get("boundary_conditions") or []
15295 if raw_blocks and isinstance(raw_blocks[0], dict):
15296 raw_blocks = [raw_blocks]
15297 for block_index, block in enumerate(raw_blocks):
15298 for entry in block or []:
15299 if not isinstance(entry, dict) or str(entry.get("handler", "")).strip().lower() != "prescribed_flow":
15300 continue
15301 source = copy.deepcopy(((entry.get("params") or {}).get("source") or {}))
15302 source.pop("output_file", None)
15303 inlet_specs.append({"block": block_index, "face": entry.get("face"), "source": source})
15304 if inlet_specs:
15305 field_slice = any((item.get("source") or {}).get("type") == "field_slice" for item in inlet_specs)
15306 providers.append({
15307 "kind": "inlet-profiles",
15308 "provider": "prescribed-flow",
15309 "execution": "precomputable",
15310 "dependencies": ["grid"] if field_slice or grid_mode in _FILE_BACKED_GRID_VALUES else [],
15311 "spec": inlet_specs,
15312 })
15313
15314 # A provider's identity has to cover every case value its build reads, not only its
15315 # own configuration subtree. The grid build nondimensionalizes by
15316 # properties.scaling.length_ref and validates against the declared block count;
15317 # both are outside `grid:`, and a change to either produces different payload bytes
15318 # from an identical provider spec. Left out, the staleness check reports `reuse`
15319 # and the solver silently receives geometry scaled by the wrong reference length.
15320 scaling = (case_cfg.get("properties") or {}).get("scaling") or {}
15321 fluid = (case_cfg.get("properties") or {}).get("fluid") or {}
15322 domain_blocks = (case_cfg.get("models") or {}).get("domain", {}).get("blocks", 1)
15323 build_contexts = {
15324 "grid": {
15325 "length_ref": scaling.get("length_ref"),
15326 "blocks": domain_blocks,
15327 },
15328 # The IC build resolves fluid scaling and the prepared boundary conditions.
15329 "initial-condition": {
15330 "length_ref": scaling.get("length_ref"),
15331 "velocity_ref": scaling.get("velocity_ref"),
15332 "density": fluid.get("density"),
15333 "viscosity": fluid.get("viscosity"),
15334 "boundary_conditions": case_cfg.get("boundary_conditions"),
15335 },
15336 # Profile generation dimensionalizes against the same scaling contract.
15337 "inlet-profiles": {
15338 "length_ref": scaling.get("length_ref"),
15339 "velocity_ref": scaling.get("velocity_ref"),
15340 "blocks": domain_blocks,
15341 },
15342 }
15343
15344 # Hash in dependency order so a provider built on top of another re-identifies when
15345 # the thing it was built from changes.
15346 by_kind = {provider["kind"]: provider for provider in providers}
15347 resolved_order = sorted(
15348 providers, key=lambda provider: len(provider.get("dependencies") or [])
15349 )
15350 for provider in resolved_order:
15351 provider["build_context"] = build_contexts.get(provider["kind"], {})
15352 provider["source_files"] = _provider_source_fingerprints(provider["spec"], case_path)
15353 provider["software"] = {
15354 "release_version": PICURV_RELEASE_VERSION,
15355 "git_commit": PICURV_BUILD.get("git_commit"),
15356 }
15357 provider["spec_sha256"] = _stable_mapping_sha256({
15358 "kind": provider["kind"],
15359 "provider": provider["provider"],
15360 "spec": provider["spec"],
15361 "build_context": provider["build_context"],
15362 "source_files": provider["source_files"],
15363 "software": provider["software"],
15364 "dependencies": {
15365 name: by_kind[name].get("spec_sha256")
15366 for name in (provider.get("dependencies") or [])
15367 if name in by_kind
15368 },
15369 })
15370 return {
15371 "case_sha256": _stable_mapping_sha256(case_cfg),
15372 "providers": providers,
15373 }
15374
15375
15376def _asset_selection(graph: dict, requested=None, *, precomputable_only: bool = False) -> list:
15377 """!
15378 @brief Resolve requested asset kinds plus dependency closure.
15379 @param[in] graph Provider graph returned by build_case_asset_graph.
15380 @param[in] requested Optional iterable of requested kind names.
15381 @param[in] precomputable_only Exclude runtime-C providers for normal-run staging.
15382 @return Ordered provider list.
15383 """
15384 by_kind = {item["kind"]: item for item in graph["providers"]}
15385 requested_set = set(requested or by_kind)
15386 unknown = requested_set - set(by_kind)
15387 if unknown:
15388 raise ValueError(
15389 f"No configured provider exists for asset kind(s): {sorted(unknown)}. "
15390 f"Configured kinds: {sorted(by_kind)}."
15391 )
15392 closure = set()
15393
15394 def add(kind):
15395 """!
15396 @brief Add one requested asset kind and its dependency closure.
15397 @param[in] kind Asset kind to include.
15398 @return None.
15399 """
15400 if kind in closure:
15401 return
15402 closure.add(kind)
15403 for dependency in by_kind[kind].get("dependencies", []):
15404 if dependency in by_kind:
15405 add(dependency)
15406
15407 for kind in requested_set:
15408 add(kind)
15409 selected = [item for item in graph["providers"] if item["kind"] in closure]
15410 if precomputable_only:
15411 selected = [item for item in selected if item["execution"] == "precomputable"]
15412 return selected
15413
15414
15415def _asset_payload_files(build_root: str, kind: str) -> list:
15416 """!
15417 @brief Enumerate canonical files belonging to one asset kind in a build tree.
15418 @param[in] build_root Temporary run-like build root.
15419 @param[in] kind Asset kind.
15420 @return Absolute payload file paths.
15421 """
15422 roots = {
15423 "grid": ["inputs/grid", "output/analysis/metrics", "output/visualization/precompute"],
15424 "initial-condition": ["inputs/initial_condition", "output/analysis/spectra", "output/analysis/metrics"],
15425 "inlet-profiles": ["inputs/inlet_profiles"],
15426 }[kind]
15427 result = []
15428 for relative in roots:
15429 root = Path(build_root, *relative.split("/"))
15430 if root.is_dir():
15431 result.extend(str(path) for path in sorted(root.rglob("*")) if path.is_file())
15432 intermediate_names = {
15433 "grid": {"grid.generated.picgrid", "grid.converted.picgrid"},
15434 "initial-condition": {"initial_condition.generated.dat"},
15435 "inlet-profiles": set(),
15436 }[kind]
15437 return [
15438 path for path in dict.fromkeys(result)
15439 if os.path.basename(path) not in intermediate_names
15440 ]
15441
15442
15443def _build_selected_asset_payloads(build_root: str, case_cfg: dict, case_path: str,
15444 selected: list) -> dict:
15445 """!
15446 @brief Execute existing generators into one isolated run-like build tree.
15447 @param[in] build_root Temporary output root.
15448 @param[in] case_cfg Parsed case config.
15449 @param[in] case_path Owning case path.
15450 @param[in] selected Ordered selected providers.
15451 @return Asset-kind to generated file list.
15452 """
15453 ensure_run_layout(build_root)
15454 selected_kinds = {item["kind"] for item in selected}
15455 payloads = {}
15456
15457 def fingerprint(kind):
15458 """!
15459 @brief Snapshot current payload checksums for one asset kind.
15460 @param[in] kind Asset kind to inspect.
15461 @return Absolute-path to checksum mapping.
15462 """
15463 return {
15464 path: _asset_file_sha256(path)
15465 for path in _asset_payload_files(build_root, kind)
15466 }
15467
15468 def record_changes(kind, before):
15469 """!
15470 @brief Record files created or changed by one provider stage.
15471 @param[in] kind Asset kind whose build just completed.
15472 @param[in] before Pre-build checksum mapping.
15473 @return None.
15474 """
15475 after = fingerprint(kind)
15476 payloads[kind] = [
15477 path for path, digest in after.items()
15478 if before.get(path) != digest
15479 ]
15480
15481 grid_cfg = case_cfg.get("grid", {}) or {}
15482 scaling = (case_cfg.get("properties", {}) or {}).get("scaling", {}) or {}
15483 length_ref = float(scaling.get("length_ref", 1.0))
15484 expected_nblk = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
15485 staged_grid = os.path.join(build_root, "inputs", "grid", "grid.run")
15486 if "grid" in selected_kinds:
15487 before = fingerprint("grid")
15488 if grid_cfg.get("mode") == "grid_gen":
15489 generated = run_grid_generator(case_path, build_root, grid_cfg, case_cfg=case_cfg)
15491 generated, staged_grid, length_ref, expected_nblk=expected_nblk
15492 )
15493 elif grid_cfg.get("mode") == "file":
15495 grid_cfg.get("source_file"), os.path.dirname(os.path.abspath(case_path))
15496 )
15498 source, staged_grid, length_ref, expected_nblk=expected_nblk
15499 )
15500 record_changes("grid", before)
15501
15502 if "inlet-profiles" in selected_kinds:
15503 before = fingerprint("inlet-profiles")
15505 build_root, "asset-build", case_cfg,
15506 {"Case": case_path},
15507 )
15508 record_changes("inlet-profiles", before)
15509
15510 if "initial-condition" in selected_kinds:
15511 before = fingerprint("initial-condition")
15512 fluid_scaling = resolve_fluid_scaling(case_cfg)
15514 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
15516 U_ref=fluid_scaling["velocity_ref"],
15517 provider_context={
15518 "kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]
15519 },
15520 )
15521 if (grid_cfg.get("mode") == "programmatic_c" and is_generated_ic_provider(resolved_ic)
15522 and not os.path.isfile(staged_grid)):
15523 # "grid" is excluded from this closure when the caller passed
15524 # precomputable_only=True (materialize_run_assets, staging before a solve): a
15525 # runtime-C grid is never itself precomputed. But the Python IC generator run
15526 # below is a separate process from the solver and needs real coordinates on
15527 # disk regardless. This bridge uses the identical formula as
15528 # ComputeStretchedCoord in src/grid.c, so it is guaranteed to match what the
15529 # solver builds moments later; it is not published as its own asset, since a
15530 # programmatic_c grid is never a persisted asset in its own right.
15532 grid_cfg.get("programmatic_settings", {}), staged_grid, length_ref
15533 )
15534 stage_initial_condition_file(build_root, case_path, resolved_ic)
15535 record_changes("initial-condition", before)
15536 return payloads
15537
15538
15539#: Largest grid, in nodes, for which a published asset carries an inline VTS preview.
15540#: Beyond this the ASCII payload costs more than the inspection is worth, and
15541#: validation.json records that it was skipped rather than silently omitting it.
15542ASSET_PREVIEW_NODE_LIMIT = 2_000_000
15543
15544
15545def _picgrid_geometry_summary(path: str) -> dict:
15546 """!
15547 @brief Read a canonical PICGRID and summarize what a user would want to check.
15548 @param[in] path Staged PICGRID file.
15549 @return Mapping of block dimensions, bounds, and spacing extremes.
15550 """
15552 bounds = [[float("inf")] * 3, [float("-inf")] * 3]
15553 nodes = 0
15554 with open(path, "r", encoding="utf-8", errors="replace") as stream:
15555 iterator = _iter_nonempty_noncomment_lines(stream)
15556 # Header token, block count, and one dimension row per block.
15557 for _ in range(2 + len(dims)):
15558 next(iterator, None)
15559 for _lineno, line in iterator:
15560 parts = line.split()
15561 if len(parts) != 3:
15562 continue
15563 try:
15564 point = [float(value) for value in parts]
15565 except ValueError:
15566 continue
15567 nodes += 1
15568 for axis in range(3):
15569 bounds[0][axis] = min(bounds[0][axis], point[axis])
15570 bounds[1][axis] = max(bounds[1][axis], point[axis])
15571 extent = [bounds[1][axis] - bounds[0][axis] for axis in range(3)] if nodes else [0.0, 0.0, 0.0]
15572 return {
15573 "blocks": len(dims),
15574 "dimensions": [list(item) for item in dims],
15575 "total_nodes": nodes,
15576 "bounds_min": bounds[0] if nodes else None,
15577 "bounds_max": bounds[1] if nodes else None,
15578 "extent": extent,
15579 }
15580
15581
15582def _write_structured_grid_preview(picgrid_path: str, destination: str, dims) -> bool:
15583 """!
15584 @brief Write a single-block ASCII VTS preview of a staged PICGRID.
15585
15586 @details Only the first block is written: the preview exists so a user can confirm
15587 the shape they configured, not to reproduce the solver's view of a
15588 multi-block domain.
15589 @param[in] picgrid_path Staged PICGRID file.
15590 @param[in] destination Preview path to write.
15591 @param[in] dims Per-block dimension triples.
15592 @return True when a preview was written.
15593 """
15594 if not dims:
15595 return False
15596 im, jm, km = (int(value) for value in dims[0])
15597 if im * jm * km > ASSET_PREVIEW_NODE_LIMIT:
15598 return False
15599 coordinates = []
15600 with open(picgrid_path, "r", encoding="utf-8", errors="replace") as stream:
15601 iterator = _iter_nonempty_noncomment_lines(stream)
15602 for _ in range(2 + len(dims)):
15603 next(iterator, None)
15604 for _lineno, line in iterator:
15605 parts = line.split()
15606 if len(parts) == 3:
15607 coordinates.append(line)
15608 if len(coordinates) >= im * jm * km:
15609 break
15610 if len(coordinates) < im * jm * km:
15611 return False
15612 os.makedirs(os.path.dirname(destination), exist_ok=True)
15613 with open(destination, "w", encoding="utf-8") as out:
15614 extent = f"0 {im - 1} 0 {jm - 1} 0 {km - 1}"
15615 out.write('<?xml version="1.0"?>\n')
15616 out.write('<VTKFile type="StructuredGrid" version="1.0" byte_order="LittleEndian">\n')
15617 out.write(f' <StructuredGrid WholeExtent="{extent}">\n')
15618 out.write(f' <Piece Extent="{extent}">\n')
15619 out.write(' <Points>\n')
15620 out.write(' <DataArray type="Float64" NumberOfComponents="3" format="ascii">\n')
15621 for row in coordinates:
15622 out.write(f" {row}\n")
15623 out.write(' </DataArray>\n </Points>\n')
15624 out.write(' </Piece>\n </StructuredGrid>\n</VTKFile>\n')
15625 return True
15626
15627
15628def _build_asset_inspection(kind: str, build_root: str, provider: dict,
15629 payload_files: list) -> dict:
15630 """!
15631 @brief Produce the inspection material published beside an asset's payload.
15632
15633 @details Precompute exists so a user can look at a grid, field, or profile and
15634 change it before committing a solve. An asset that carries only opaque
15635 solver input cannot serve that purpose, so every published object gets a
15636 validation record and, where it is affordable, a preview.
15637 @param[in] kind Asset kind.
15638 @param[in] build_root Temporary build tree.
15639 @param[in] provider Provider metadata for the asset.
15640 @param[in] payload_files Absolute payload paths produced for this asset.
15641 @return Mapping of published inspection filename to its absolute source path.
15642 """
15643 inspection_dir = os.path.join(build_root, ".inspection")
15644 os.makedirs(inspection_dir, exist_ok=True)
15645 published = {}
15646 validation = {
15647 "asset_kind": kind,
15648 "provider": provider["provider"],
15649 "generated_at": datetime.now().astimezone().isoformat(),
15650 "files": [os.path.relpath(path, build_root).replace(os.sep, "/") for path in payload_files],
15651 }
15652
15653 if kind == "grid":
15654 staged = os.path.join(build_root, "inputs", "grid", "grid.run")
15655 if os.path.isfile(staged):
15656 try:
15657 geometry = _picgrid_geometry_summary(staged)
15658 validation["geometry"] = geometry
15659 preview = os.path.join(inspection_dir, "preview.vts")
15660 if _write_structured_grid_preview(staged, preview, geometry["dimensions"]):
15661 published["preview.vts"] = preview
15662 else:
15663 validation["preview"] = (
15664 "skipped: the first block exceeds "
15665 f"{ASSET_PREVIEW_NODE_LIMIT} nodes"
15666 )
15667 except (OSError, ValueError, StopIteration) as exc:
15668 validation["geometry_error"] = str(exc)
15669 generator_preview = os.path.join(
15670 build_root, "output", "visualization", "precompute", "grid.vts"
15671 )
15672 if os.path.isfile(generator_preview):
15673 published["preview.vts"] = generator_preview
15674 validation.pop("preview", None)
15675 info = os.path.join(build_root, "output", "analysis", "metrics", "grid.info")
15676 if os.path.isfile(info):
15677 published["grid.info"] = info
15678
15679 elif kind == "initial-condition":
15680 # The generators name these for the run tree they were written into; the asset
15681 # publishes them under the names the object contract uses.
15682 for published_name, suffix in (
15683 ("summary.json", "_summary.json"), ("spectrum.csv", "_spectrum.csv"),
15684 ):
15685 for candidate in payload_files:
15686 base = os.path.basename(candidate)
15687 if base == published_name or base.endswith(suffix):
15688 published[published_name] = candidate
15689 break
15690 validation["fields"] = sorted(
15691 os.path.basename(path) for path in payload_files if path.endswith(".dat")
15692 )
15693 summary_source = published.get("summary.json")
15694 if summary_source:
15695 try:
15696 with open(summary_source, "r", encoding="utf-8") as stream:
15697 validation["summary"] = json.load(stream)
15698 except (OSError, ValueError):
15699 pass
15700
15701 elif kind == "inlet-profiles":
15702 for candidate in payload_files:
15703 if os.path.basename(candidate) == "profile.info":
15704 published["profile.info"] = candidate
15705 validation["profiles"] = sorted(
15706 os.path.basename(path) for path in payload_files
15707 if path.endswith(".picslice")
15708 )
15709
15710 validation_path = os.path.join(inspection_dir, "validation.json")
15711 write_json_file(validation_path, validation)
15712 published["validation.json"] = validation_path
15713 return published
15714
15715
15716def _publish_asset_object(workspace_root: str, build_root: str, provider: dict,
15717 payload_files: list) -> dict:
15718 """!
15719 @brief Publish one immutable content-addressed asset object atomically.
15720 @param[in] workspace_root Owning workspace.
15721 @param[in] build_root Temporary build root.
15722 @param[in] provider Provider metadata.
15723 @param[in] payload_files Files produced for the provider.
15724 @return Asset reference written to the asset set.
15725 """
15726 file_inventory = []
15727 for path in payload_files:
15728 relative = os.path.relpath(path, build_root).replace(os.sep, "/")
15729 file_inventory.append({
15730 "path": relative,
15731 "bytes": os.path.getsize(path),
15732 "sha256": _asset_file_sha256(path),
15733 })
15734 asset_id = _stable_mapping_sha256({
15735 "provider_spec_sha256": provider["spec_sha256"],
15736 "files": file_inventory,
15737 })
15738 # Identity is the payload's; inspection material describes it and must not change
15739 # which object a run resolves to.
15740 inspection = _build_asset_inspection(
15741 provider["kind"], build_root, provider, payload_files
15742 )
15743 kind_dir = ASSET_KIND_DIRECTORIES[provider["kind"]]
15744 object_root = os.path.join(workspace_root, "assets", "objects", kind_dir, asset_id)
15745 if not os.path.isdir(object_root):
15746 parent = os.path.dirname(object_root)
15747 os.makedirs(parent, exist_ok=True)
15748 temporary = tempfile.mkdtemp(prefix=f".{asset_id[:12]}-", dir=parent)
15749 try:
15750 for item, source in zip(file_inventory, payload_files):
15751 destination = os.path.join(temporary, "payload", *item["path"].split("/"))
15752 os.makedirs(os.path.dirname(destination), exist_ok=True)
15753 shutil.copy2(source, destination)
15754 for name, source in sorted(inspection.items()):
15755 shutil.copy2(source, os.path.join(temporary, name))
15756 manifest = {
15757 "schema_version": ASSET_MANIFEST_SCHEMA_VERSION,
15758 "asset_id": asset_id,
15759 "kind": provider["kind"],
15760 "provider": provider["provider"],
15761 "provider_execution": provider["execution"],
15762 "provider_spec_sha256": provider["spec_sha256"],
15763 "provider_spec": provider["spec"],
15764 "source_files": provider["source_files"],
15765 "software": provider["software"],
15766 "created_at": datetime.now().astimezone().isoformat(),
15767 "files": file_inventory,
15768 "inspection": sorted(inspection),
15769 }
15770 write_json_file(os.path.join(temporary, "asset.json"), manifest)
15771 try:
15772 os.replace(temporary, object_root)
15773 except OSError as exc:
15774 if exc.errno != errno.ENOTEMPTY and not os.path.isdir(object_root):
15775 raise
15776 finally:
15777 if os.path.isdir(temporary):
15778 shutil.rmtree(temporary, ignore_errors=True)
15779 return {
15780 "asset_id": asset_id,
15781 "kind": provider["kind"],
15782 "provider": provider["provider"],
15783 "provider_spec_sha256": provider["spec_sha256"],
15784 "object": os.path.relpath(object_root, workspace_root).replace(os.sep, "/"),
15785 "files": file_inventory,
15786 "inspection": sorted(inspection),
15787 }
15788
15789
15790def _workspace_asset_set_name(workspace_root: str, case_path: str) -> str:
15791 """!
15792 @brief Return a collision-free, readable mutable asset-set name.
15793 @param[in] workspace_root Owning initialized workspace.
15794 @param[in] case_path Source case configuration path.
15795 @return Stable asset-set filename stem.
15796 """
15797 stem = re.sub(r"[^A-Za-z0-9_.-]+", "-", Path(case_path).stem).strip("-") or "case"
15798 relative = _relative_to_workspace(case_path, workspace_root) or os.path.abspath(case_path)
15799 digest = hashlib.sha256(str(relative).encode("utf-8")).hexdigest()[:10]
15800 return f"{stem}-{digest}"
15801
15802
15803def _write_workspace_asset_set(workspace_root: str, case_path: str, graph: dict,
15804 references: dict) -> str:
15805 """!
15806 @brief Atomically update the named asset set and workspace asset catalog.
15807 @param[in] workspace_root Owning workspace.
15808 @param[in] case_path Source case YAML.
15809 @param[in] graph Complete provider graph.
15810 @param[in] references Newly published or reused asset references.
15811 @return Asset-set YAML path.
15812 """
15813 name = _workspace_asset_set_name(workspace_root, case_path)
15814 set_path = os.path.join(workspace_root, "assets", "sets", f"{name}.yml")
15815 existing = read_yaml_file(set_path) if os.path.isfile(set_path) else {}
15816 assets = dict(existing.get("assets") or {})
15817 assets.update(references)
15818 payload = {
15819 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
15820 "name": name,
15821 "case": os.path.relpath(case_path, workspace_root).replace(os.sep, "/"),
15822 "case_sha256": graph["case_sha256"],
15823 "updated_at": datetime.now().astimezone().isoformat(),
15824 "assets": assets,
15825 "runtime_providers": {
15826 item["kind"]: {
15827 "provider": item["provider"],
15828 "provider_spec_sha256": item["spec_sha256"],
15829 }
15830 for item in graph["providers"] if item["execution"] == "runtime-c"
15831 },
15832 }
15833 write_yaml_file(set_path, payload)
15834 catalog_path = os.path.join(workspace_root, "assets", "catalog.yml")
15835 catalog = read_yaml_file(catalog_path) if os.path.isfile(catalog_path) else {
15836 "schema_version": 1, "objects": {}
15837 }
15838 objects = catalog.setdefault("objects", {})
15839 for reference in references.values():
15840 objects[reference["asset_id"]] = {
15841 "kind": reference["kind"],
15842 "provider": reference["provider"],
15843 "object": reference["object"],
15844 }
15845 write_yaml_file(catalog_path, catalog)
15846 return set_path
15847
15848
15849def precompute_case_assets(workspace_root: str, case_cfg: dict, case_path: str,
15850 requested=None, precomputable_only: bool = False) -> dict:
15851 """!
15852 @brief Build and publish a selected deterministic asset dependency closure.
15853 @param[in] workspace_root Owning workspace.
15854 @param[in] case_cfg Parsed case configuration.
15855 @param[in] case_path Source case path.
15856 @param[in] requested Requested asset kinds, or all configured providers.
15857 @param[in] precomputable_only Drop runtime-C dependencies from the closure instead of
15858 refusing. For internal run-staging only: a run legitimately
15859 depends on a runtime-C grid that its own solver builds
15860 moments later, so its presence in the dependency closure is
15861 expected there, not an error. The standalone `picurv
15862 precompute` command leaves this False, since nothing will
15863 ever build a runtime-C provider in that context.
15864 @return Provider graph, selected providers, references, and asset-set path.
15865 """
15866 enforce_workspace_version(workspace_root)
15867 graph = build_case_asset_graph(case_cfg, case_path)
15868 selected = _asset_selection(graph, requested, precomputable_only=precomputable_only)
15869 if not precomputable_only:
15870 runtime = [item for item in selected if item["execution"] == "runtime-c"]
15871 if runtime:
15872 details = ", ".join(f"{item['kind']}={item['provider']}" for item in runtime)
15873 raise ValueError(
15874 "Precompute is atomic and cannot execute simulator-runtime providers. "
15875 f"Selected dependency graph requires C generation: {details}. "
15876 "Use --only to select an independent precomputable subset, or run the case; "
15877 "the simulator will report each runtime provider before generation."
15878 )
15879 if not selected:
15880 raise ValueError("The selected case has no precomputable providers.")
15881 staging_parent = os.path.join(workspace_root, "assets")
15882 os.makedirs(staging_parent, exist_ok=True)
15883 build_root = tempfile.mkdtemp(prefix=".precompute-", dir=staging_parent)
15884 references = {}
15885 try:
15886 payloads = _build_selected_asset_payloads(build_root, case_cfg, case_path, selected)
15887 for provider in selected:
15888 files = payloads.get(provider["kind"], [])
15889 if not files:
15890 raise ValueError(
15891 f"Provider {provider['kind']}={provider['provider']} produced no files."
15892 )
15893 references[provider["kind"]] = _publish_asset_object(
15894 workspace_root, build_root, provider, files
15895 )
15896 finally:
15897 shutil.rmtree(build_root, ignore_errors=True)
15898 set_path = _write_workspace_asset_set(workspace_root, case_path, graph, references)
15899 return {"graph": graph, "selected": selected, "assets": references, "set_path": set_path}
15900
15901
15902def _workspace_asset_set_path(workspace_root: str, case_path: str) -> str:
15903 """!
15904 @brief Return the mutable asset-set pointer associated with a case config name.
15905 @param[in] workspace_root Owning workspace.
15906 @param[in] case_path Source case path.
15907 @return Absolute asset-set YAML path.
15908 """
15909 name = _workspace_asset_set_name(workspace_root, case_path)
15910 return os.path.join(workspace_root, "assets", "sets", f"{name}.yml")
15911
15912
15913def plan_run_assets(case_cfg: dict, case_path: str) -> dict:
15914 """!
15915 @brief Plan reuse/build/runtime actions for all configured run providers.
15916 @param[in] case_cfg Parsed case configuration.
15917 @param[in] case_path Source case path.
15918 @return Workspace, graph, set path, and per-provider actions.
15919 """
15920 workspace_root = find_workspace_root(case_path)
15921 graph = build_case_asset_graph(case_cfg, case_path)
15922 asset_set = {}
15923 set_path = None
15924 if workspace_root:
15925 set_path = _workspace_asset_set_path(workspace_root, case_path)
15926 if os.path.isfile(set_path):
15927 asset_set = read_yaml_file(set_path)
15928 refs = asset_set.get("assets", {}) if isinstance(asset_set, dict) else {}
15929 actions = []
15930 for provider in graph["providers"]:
15931 reference = refs.get(provider["kind"]) if isinstance(refs, dict) else None
15932 matches = bool(
15933 isinstance(reference, dict)
15934 and reference.get("provider_spec_sha256") == provider["spec_sha256"]
15935 and workspace_root
15936 and os.path.isfile(os.path.join(
15937 workspace_root, reference.get("object", ""), "asset.json"
15938 ))
15939 )
15940 if provider["execution"] == "runtime-c":
15941 action = "runtime-c"
15942 elif matches:
15943 action = "reuse"
15944 else:
15945 action = "build"
15946 actions.append({**provider, "action": action, "reference": reference if matches else None})
15947 return {
15948 "workspace_root": workspace_root,
15949 "graph": graph,
15950 "asset_set_path": set_path,
15951 "actions": actions,
15952 }
15953
15954
15955def _materialize_asset_file(source: str, destination: str) -> str:
15956 """!
15957 @brief Expose one immutable shared-asset file through reflink, hardlink, or copy.
15958 @param[in] source Immutable asset payload file.
15959 @param[in] destination Run-local destination.
15960 @return Materialization mode used.
15961 """
15962 os.makedirs(os.path.dirname(destination), exist_ok=True)
15963 temporary = f"{destination}.tmp.{os.getpid()}"
15964 try:
15965 cp = shutil.which("cp")
15966 if cp:
15967 result = subprocess.run(
15968 [cp, "--reflink=always", "--preserve=mode,timestamps", source, temporary],
15969 text=True, capture_output=True, check=False,
15970 )
15971 if result.returncode == 0:
15972 os.replace(temporary, destination)
15973 return "reflink"
15974 if os.path.lexists(temporary):
15975 os.remove(temporary)
15976 try:
15977 os.link(source, temporary)
15978 os.replace(temporary, destination)
15979 return "hardlink"
15980 except OSError:
15981 if os.path.lexists(temporary):
15982 os.remove(temporary)
15983 shutil.copy2(source, temporary)
15984 os.replace(temporary, destination)
15985 return "copy"
15986 finally:
15987 if os.path.lexists(temporary):
15988 os.remove(temporary)
15989
15990
15991def materialize_run_assets(run_dir: str, case_cfg: dict, case_path: str,
15992 require_precomputed: bool = False,
15993 fetch_missing: bool = False) -> dict:
15994 """!
15995 @brief Resolve/build workspace assets and write the exact run input lock.
15996 @param[in] run_dir Run receiving immutable input exposures.
15997 @param[in] case_cfg Parsed case config.
15998 @param[in] case_path Source case path.
15999 @param[in] require_precomputed Refuse any missing deterministic asset.
16000 @param[in] fetch_missing Request remote fetch before local build.
16001 @return Written lock mapping, or a standalone-provider summary outside a workspace.
16002 """
16003 plan = plan_run_assets(case_cfg, case_path)
16004 workspace_root = plan["workspace_root"]
16005 if not workspace_root:
16006 return {
16007 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
16008 "workspace": None,
16009 "assets": {},
16010 "runtime_providers": {
16011 item["kind"]: item["provider"] for item in plan["actions"]
16012 if item["execution"] == "runtime-c"
16013 },
16014 }
16015 enforce_workspace_version(workspace_root)
16016 missing = [item for item in plan["actions"] if item["action"] == "build"]
16017 if missing and fetch_missing:
16018 # Storage imports this module indirectly, so remote asset recovery is routed
16019 # through a late import that cannot create a module cycle at startup.
16020 try:
16021 from .storage import restore_missing_workspace_assets
16022 except ImportError:
16023 restore_missing_workspace_assets = getattr(
16024 _storage_module, "restore_missing_workspace_assets", None
16025 )
16026 restored = restore_missing_workspace_assets(
16027 workspace_root, [item["spec_sha256"] for item in missing]
16028 ) if restore_missing_workspace_assets else {}
16029 if restored:
16030 recovered_refs = {
16031 item["kind"]: restored[item["spec_sha256"]]
16032 for item in missing if item["spec_sha256"] in restored
16033 }
16034 if recovered_refs:
16036 workspace_root, case_path, plan["graph"], recovered_refs
16037 )
16038 plan = plan_run_assets(case_cfg, case_path)
16039 missing = [item for item in plan["actions"] if item["action"] == "build"]
16040 if missing and require_precomputed:
16041 names = ", ".join(f"{item['kind']}={item['provider']}" for item in missing)
16042 raise ValueError(
16043 f"Required precomputed asset(s) are missing or stale: {names}. "
16044 f"Run 'picurv precompute --case {case_path} --only "
16045 + ",".join(item["kind"] for item in missing) + "'."
16046 )
16047 if missing:
16049 workspace_root, case_cfg, case_path,
16050 requested=[item["kind"] for item in missing],
16051 precomputable_only=True,
16052 )
16053 plan = plan_run_assets(case_cfg, case_path)
16054 still_missing = [item for item in plan["actions"] if item["action"] == "build"]
16055 if still_missing:
16056 raise ValueError(
16057 "Asset generation completed without satisfying: "
16058 + ", ".join(item["kind"] for item in still_missing)
16059 )
16060
16061 assets = {}
16062 for item in plan["actions"]:
16063 reference = item.get("reference")
16064 if item["action"] != "reuse" or not isinstance(reference, dict):
16065 continue
16066 object_root = os.path.join(workspace_root, reference["object"])
16067 manifest = _read_json_if_exists(os.path.join(object_root, "asset.json"))
16068 if not isinstance(manifest, dict) or manifest.get("asset_id") != reference.get("asset_id"):
16069 raise ValueError(f"Asset object is missing or invalid: {object_root}")
16070 exposed = []
16071 for file_info in manifest.get("files", []):
16072 relative = file_info["path"]
16073 source = os.path.join(object_root, "payload", *relative.split("/"))
16074 if _asset_file_sha256(source) != file_info.get("sha256"):
16075 raise ValueError(f"Asset payload checksum mismatch: {source}")
16076 destination = os.path.join(run_dir, *relative.split("/"))
16077 mode = _materialize_asset_file(source, destination)
16078 exposed.append({"path": relative, "mode": mode, "sha256": file_info["sha256"]})
16079 assets[item["kind"]] = {**reference, "exposed": exposed}
16080 print(f"[INFO] Asset {item['kind']}: reuse {reference['asset_id']}")
16081 runtime_providers = {
16082 item["kind"]: {
16083 "provider": item["provider"],
16084 "provider_spec_sha256": item["spec_sha256"],
16085 }
16086 for item in plan["actions"] if item["execution"] == "runtime-c"
16087 }
16088 for kind, provider in runtime_providers.items():
16089 print(f"[INFO] Runtime provider {kind}: {provider['provider']} (generated by simulator)")
16090 lock = {
16091 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
16092 "workspace": workspace_root,
16093 "case_sha256": plan["graph"]["case_sha256"],
16094 "created_at": datetime.now().astimezone().isoformat(),
16095 "assets": assets,
16096 "runtime_providers": runtime_providers,
16097 }
16098 write_yaml_file(os.path.join(run_dir, "inputs", "assets.lock.yml"), lock)
16099 write_software_lock(run_dir)
16100 return lock
16101
16102
16104 """!
16105 @brief Resolve, preflight, and atomically publish reusable workspace assets.
16106 @param[in] args Parsed precompute command arguments.
16107 """
16108 case_path = os.path.abspath(args.case)
16109 workspace_root = find_workspace_root(case_path, os.getcwd())
16110 if not workspace_root:
16111 raise ValueError(
16112 "Precompute requires an initialized PICurv workspace. Run 'picurv init ...', "
16113 "then use its config/case.yml."
16114 )
16115 case_cfg = read_yaml_file(case_path)
16116 raw_only = getattr(args, "only", None)
16117 requested = None
16118 if raw_only and str(raw_only).strip().lower() != "all":
16119 requested = [token.strip() for token in str(raw_only).split(",") if token.strip()]
16120 graph = build_case_asset_graph(case_cfg, case_path)
16121 selected = _asset_selection(graph, requested)
16122 print(f"[INFO] Workspace : {workspace_root}")
16123 print(f"[INFO] Case : {os.path.relpath(case_path, workspace_root)}")
16124 print("[INFO] Asset dependency plan:")
16125 for provider in selected:
16126 dependencies = ",".join(provider.get("dependencies", [])) or "none"
16127 print(
16128 f" - {provider['kind']}: {provider['provider']} "
16129 f"[{provider['execution']}], dependencies={dependencies}"
16130 )
16131 result = precompute_case_assets(workspace_root, case_cfg, case_path, requested=requested)
16132 for kind, reference in sorted(result["assets"].items()):
16133 print(f"[SUCCESS] {kind}: {reference['asset_id']} ({reference['object']})")
16134 print(f"[SUCCESS] Asset set: {os.path.relpath(result['set_path'], workspace_root)}")
16135
16137 """!
16138 @brief Main orchestrator for the 'run' command (local and Slurm modes).
16139 @param[in] args Command-line style argument list supplied to the function.
16140 """
16141 if getattr(args, "dry_run", False):
16142 plan = build_run_dry_plan(args)
16143 render_run_dry_plan(plan, output_format=getattr(args, "output_format", "text"))
16144 if plan.get("blocking"):
16145 sys.exit(1)
16146 return
16147
16148 run_dir = None
16149 run_id = None
16150 output_dir_abs = None
16151 # A post-only invocation never resolves a restart source, so this stays None and
16152 # `build_run_manifest()` preserves whatever lineage the run was created with.
16153 run_lineage = None
16154 statistics_output_paths = []
16155 workflow_start = time.time()
16156 stages_completed = []
16157 configs = None
16158 submission_meta = {"launch_mode": "local", "no_submit": bool(args.no_submit), "stages": {}}
16159
16160 cluster_mode = bool(getattr(args, "cluster", None))
16161 cluster_cfg = None
16162 cluster_path = None
16163 solver_num_procs_effective = args.num_procs
16164 post_num_procs_effective = args.num_procs
16165
16166 if cluster_mode:
16167 cluster_path = os.path.abspath(args.cluster)
16168 cluster_cfg = read_yaml_file(cluster_path)
16169 validate_cluster_config(cluster_cfg, cluster_path)
16170 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
16171 if args.scheduler and args.scheduler.lower() != scheduler_type:
16172 print(
16173 f"[FATAL] --scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
16174 file=sys.stderr
16175 )
16176 sys.exit(1)
16177 if scheduler_type != "slurm":
16178 print(f"[FATAL] Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.", file=sys.stderr)
16179 sys.exit(1)
16180 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
16181 if (args.solve or args.post_process) and args.num_procs not in (1, cluster_tasks):
16182 print(
16183 "[FATAL] In cluster mode, --num-procs must be "
16184 f"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
16185 file=sys.stderr
16186 )
16187 sys.exit(1)
16188 if args.solve:
16189 solver_num_procs_effective = cluster_tasks
16190 if args.post_process:
16191 post_num_procs_effective = cluster_tasks
16192 submission_meta["launch_mode"] = "slurm"
16193 submission_meta["cluster_config"] = cluster_path
16194 submission_meta["no_submit"] = bool(args.no_submit)
16195 if args.solve and args.post_process:
16196 print(
16197 f"[INFO] Cluster mode enabled (Slurm). Solver and post stages use "
16198 f"{solver_num_procs_effective} MPI tasks from cluster.yml."
16199 )
16200 elif args.solve:
16201 print(f"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks from cluster.yml.")
16202 else:
16203 print(f"[INFO] Cluster mode enabled (Slurm). Post stage uses {post_num_procs_effective} MPI tasks from cluster.yml.")
16204 elif getattr(args, "scheduler", None):
16205 print("[FATAL] --scheduler requires --cluster in this version.", file=sys.stderr)
16206 sys.exit(1)
16207
16208 # --- Guard: restart flags without --solve ---
16209 if not args.solve:
16210 if getattr(args, 'restart_from', None):
16211 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
16212 if getattr(args, 'continue_run', False) and not args.post_process:
16213 print("[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
16214
16215 # --- Stage 1: Solver (if requested) ---
16216 if args.solve:
16217 case_input_path = os.path.abspath(args.case)
16218 workspace_root = find_workspace_root(case_input_path, args.solver, args.monitor, os.getcwd())
16219 if workspace_root:
16220 enforce_workspace_version(workspace_root)
16221 enforce_reproducibility_policy(workspace_root)
16222 walltime_guard_policy = resolve_walltime_guard_policy(cluster_cfg) if cluster_mode else None
16223 configs = {
16224 'case': read_yaml_file(args.case), 'case_path': case_input_path,
16225 'solver': read_yaml_file(args.solver), 'solver_path': os.path.abspath(args.solver),
16226 'monitor': read_yaml_file(args.monitor), 'monitor_path': os.path.abspath(args.monitor),
16227 'walltime_guard_policy': walltime_guard_policy,
16228 'statistics_state': getattr(args, "statistics_state", None) or "reset",
16229 }
16230
16231 print("\n[INFO] Validating configuration files...")
16233 configs['case'], configs['solver'], configs['monitor'],
16234 args.case, args.solver, args.monitor
16235 )
16236 print("[SUCCESS] All configuration files passed validation.\n")
16237
16238 continue_mode = getattr(args, 'continue_run', False)
16239
16240 if continue_mode:
16241 if not args.run_dir:
16242 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
16243 run_dir = os.path.abspath(args.run_dir)
16244 if not os.path.isdir(run_dir):
16246 ERROR_CODE_CFG_FILE_NOT_FOUND,
16247 key="run-dir",
16248 file_path=run_dir,
16249 message="Specified run directory not found.",
16250 )
16251 sys.exit(1)
16252 run_id = os.path.basename(run_dir)
16253 else:
16254 runs_root = workspace_artifact_root(workspace_root, "runs")
16256 runs_root, configs["case"], configs["case_path"]
16257 )
16258 run_dir = os.path.join(runs_root, run_id)
16259
16260 if workspace_root and os.path.commonpath([os.path.abspath(run_dir), workspace_root]) != workspace_root:
16261 fail_cli_usage("A workspace run directory must remain below the owning workspace.")
16262 if not continue_mode and os.path.exists(run_dir):
16263 raise ValueError(f"Generated run directory already exists: {run_dir}")
16264 ensure_run_layout(run_dir)
16265 # Checked after the skeleton exists so a resumed run is judged on what is
16266 # actually there, and before any stage writes into it.
16268
16269 try:
16270 resolved_restart_source_dir, is_continue, run_lineage = resolve_restart_source(
16271 args, configs["case"], configs["solver"], configs["monitor"], run_dir
16272 )
16273 except ValueError as e:
16274 # Restart resolution is the first thing that can refuse a run, and the
16275 # skeleton is already on disk. Leaving it behind puts an empty directory in
16276 # runs/ that is indistinguishable from a real run to `ls`, to storage
16277 # status, and to anyone browsing the workspace, once per refused attempt.
16278 discard_unused_run_directory(run_dir, created=not continue_mode)
16280 ERROR_CODE_CFG_INCONSISTENT_COMBO,
16281 key="restart",
16282 file_path=args.case,
16283 message=str(e),
16284 )
16285 sys.exit(1)
16286
16287 config_dir = os.path.join(run_dir, "config")
16288 if continue_mode:
16289 print(f"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
16290 else:
16291 print(f"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
16292
16293 active_config = snapshot_run_configuration(
16294 run_dir,
16295 {
16296 "case": args.case,
16297 "solver": args.solver,
16298 "monitor": args.monitor,
16299 "cluster": cluster_path if cluster_mode else None,
16300 },
16301 continuation=continue_mode,
16302 )
16303 asset_lock = materialize_run_assets(
16304 run_dir,
16305 configs["case"],
16306 configs["case_path"],
16307 require_precomputed=bool(getattr(args, "require_precomputed", False)),
16308 fetch_missing=bool(getattr(args, "fetch_missing", False)),
16309 )
16310
16311 print("\n" + "="*25 + " SOLVER STAGE " + "="*25)
16312 source_files = {'Case': args.case, 'Solver': args.solver, 'Monitor': args.monitor}
16313 generated_config_dir = (
16314 config_dir if active_config["revision"] == "initial"
16315 else os.path.join(config_dir, "history", active_config["revision"])
16316 )
16317 monitor_files = prepare_monitor_files(
16318 run_dir, run_id, configs['monitor'], source_files,
16319 config_dir=generated_config_dir,
16320 )
16321 if resolved_restart_source_dir:
16322 print(f"[INFO] Restart source: {resolved_restart_source_dir}")
16323 if is_continue:
16324 print("[INFO] Continue mode: logs will be appended, not overwritten.")
16325 control_file = generate_solver_control_file(
16326 run_dir,
16327 run_id,
16328 configs,
16329 solver_num_procs_effective,
16330 monitor_files,
16331 restart_source_dir=resolved_restart_source_dir,
16332 continue_mode=is_continue,
16333 config_dir=generated_config_dir,
16334 )
16336 run_dir,
16337 [
16338 control_file,
16339 monitor_files.get("whitelist"),
16340 monitor_files.get("profile"),
16341 *glob.glob(os.path.join(generated_config_dir, "bcs*.run")),
16342 ],
16343 )
16344 control_file = load_active_run_configuration(run_dir).get("control", control_file)
16345
16346 solver_exe = resolve_runtime_executable("simulator")
16347 # Staging is the last point before the binary's identity becomes the one written
16348 # into this run's checkpoints, so a stale build is worth saying out loud here.
16350 solver_args = build_petsc_diagnostics_args(configs["monitor"], run_dir, "Solver") + ["-control_file", control_file]
16351 if cluster_mode:
16352 scheduler_dir = os.path.join(run_dir, "scheduler")
16353 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
16354 solver_log = os.path.join(scheduler_dir, "solver_%j.out")
16355 solver_err = os.path.join(scheduler_dir, "solver_%j.err")
16356 solver_cmd = build_cluster_launch_command(
16357 cluster_cfg,
16358 solver_exe,
16359 solver_args,
16360 config_search_anchor=args.case,
16361 extra_search_anchors=[cluster_path],
16362 )
16364 solver_script,
16365 f"{run_id}_solve",
16366 cluster_cfg,
16367 solver_cmd,
16368 run_dir,
16369 solver_log,
16370 solver_err,
16371 env_vars={"LOG_LEVEL": configs['monitor'].get('logging', {}).get('verbosity', 'INFO').upper()},
16372 shell_env_vars=build_walltime_guard_exports(cluster_cfg),
16373 )
16374 submission_meta["stages"]["solve"] = {
16375 "script": solver_script,
16376 "submitted": False,
16377 "num_procs_effective": solver_num_procs_effective,
16378 }
16379 print(f"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
16380 if not args.no_submit:
16381 submit_info = submit_sbatch(solver_script)
16382 submission_meta["stages"]["solve"].update(submit_info)
16383 submission_meta["stages"]["solve"]["submitted"] = True
16384 print(f"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
16385 stages_completed.append('solve')
16386 else:
16388 solver_exe,
16389 solver_args,
16390 solver_num_procs_effective,
16391 config_search_anchor=configs["case_path"],
16392 )
16393 solver_log = os.path.join("scheduler", f"{run_id}_solver.log")
16394 submission_meta["stages"]["solve"] = {
16395 "command": command,
16396 "command_string": format_command_for_display(command),
16397 "log_file": solver_log,
16398 "submitted": False,
16399 "num_procs_effective": solver_num_procs_effective,
16400 }
16401 if args.no_submit:
16402 print(f"[SUCCESS] Staged local solver command: {solver_log}")
16403 else:
16404 try:
16405 with runtime_stage_lock(run_dir, "solver"):
16406 execute_command(command, run_dir, solver_log, configs['monitor'])
16407 except StorageError as exc:
16408 print(f"[FATAL] {exc}", file=sys.stderr)
16409 sys.exit(1)
16410 submission_meta["stages"]["solve"]["submitted"] = True
16411 submission_meta["stages"]["solve"]["executed"] = True
16412 submission_meta["stages"]["solve"]["completed_at"] = datetime.now().isoformat()
16413 stages_completed.append('solve')
16414
16415 # --- Stage 2: Post-Processing (if requested) ---
16416 if args.post_process:
16417 if args.run_dir:
16418 run_dir = os.path.abspath(args.run_dir)
16419 if not os.path.isdir(run_dir):
16420 print(f"[FATAL] Specified run directory not found: {run_dir}", file=sys.stderr)
16421 sys.exit(1)
16422 print(f"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
16424 run_id = read_artifact_identity(run_dir)["run_id"]
16425 elif not args.solve:
16426 print("[FATAL] --post-process requires --run-dir when not used with --solve.", file=sys.stderr)
16427 sys.exit(1)
16428
16429 print("\n" + "="*20 + " POST-PROCESSING STAGE " + "="*20)
16430 config_dir = os.path.join(run_dir, "config")
16431 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
16432
16433 if not all([case_path, monitor_path, solver_control_path]):
16434 print(f"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
16435 if not case_path:
16436 print(" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
16437 if not monitor_path:
16438 print(" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
16439 if not solver_control_path:
16440 print(" - No '.control' file found.", file=sys.stderr)
16441 sys.exit(1)
16442
16443 print(f"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
16444 print(f"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
16445
16446 case_cfg = read_yaml_file(case_path)
16447 monitor_cfg = read_yaml_file(monitor_path)
16448 post_cfg = read_yaml_file(args.post)
16449
16450 requested_start, requested_end, requested_interval = resolve_post_requested_window(post_cfg, case_cfg)
16451 try:
16452 require_storage_payload_local(
16453 run_dir,
16454 "post-processing",
16455 checkpoints=range(requested_start, requested_end + 1, requested_interval),
16456 )
16457 except StorageError as exc:
16459 ERROR_CODE_CFG_FILE_NOT_FOUND,
16460 key="storage",
16461 file_path=run_dir,
16462 message=str(exc),
16463 )
16464 sys.exit(1)
16465
16466 print("[INFO] Validating post-processing configuration...")
16467 validate_post_config(post_cfg, args.post, monitor_cfg, case_cfg)
16468 print("[SUCCESS] Post-processing configuration passed validation.\n")
16469
16470 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
16471 for relative in (
16472 post_cfg["_picurv_paths"]["visualization"],
16473 post_cfg["_picurv_paths"]["statistics"],
16474 post_cfg["_picurv_paths"]["spectra"],
16475 ):
16476 os.makedirs(os.path.join(run_dir, relative), exist_ok=True)
16477 archived_post_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.yml")
16478 os.makedirs(os.path.dirname(archived_post_path), exist_ok=True)
16479 if os.path.abspath(args.post) != os.path.abspath(archived_post_path):
16480 shutil.copy2(args.post, archived_post_path)
16481
16482 solver_sources_deferred = bool(args.solve and (cluster_mode or args.no_submit))
16483 allow_source_frontier_scan = not solver_sources_deferred
16484 post_plan = build_post_execution_plan(
16485 run_dir,
16486 run_id,
16487 case_cfg,
16488 monitor_cfg,
16489 post_cfg,
16490 continue_requested=getattr(args, 'continue_run', False),
16491 allow_source_frontier_scan=allow_source_frontier_scan,
16492 )
16493
16494 post_stages = resolve_post_stage_selection(getattr(args, 'only', None))
16495 if post_stages != set(POST_STAGE_NAMES):
16496 print(f"[INFO] Post stages selected: {','.join(sorted(post_stages))}")
16497
16498 print(f"[INFO] Post recipe: {recipe_id}")
16499 print(f"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
16500
16501 if getattr(args, 'continue_run', False):
16502 if post_plan['resume_recipe_match']:
16503 print(f"[INFO] Post resume recipe match: yes ({post_plan['resume_match_source']}).")
16504 else:
16505 print("[INFO] Post resume recipe match: no. Using the configured start_step for this recipe.")
16506 if post_plan['completed_frontier_step'] is not None:
16507 print(f"[INFO] Completed post frontier: step {post_plan['completed_frontier_step']}")
16508 else:
16509 print("[INFO] Completed post frontier: none")
16510 if post_plan['source_frontier_deferred']:
16511 print("[INFO] Source availability frontier: deferred because the solver stage will populate the requested window before post starts.")
16512 elif post_plan['source_frontier_step'] is not None:
16513 print(f"[INFO] Current source availability frontier: step {post_plan['source_frontier_step']}")
16514 else:
16515 print("[INFO] Current source availability frontier: none")
16516
16517 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['completed_frontier_step'])
16518
16519 if post_plan['skip_reason'] == 'already-complete-window':
16520 print("[INFO] Requested post window is already complete; skipping postprocessor launch.")
16521 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['requested_end_step'])
16522 elif post_plan['skip_reason'] == 'already-caught-up-to-current-source-frontier':
16523 print("[INFO] Post outputs are already caught up to the current fully available source frontier; nothing new to launch right now.")
16524 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
16525 first_incomplete = diagnostic.get('first_incomplete_step')
16526 if first_incomplete is not None:
16527 print(f"[INFO] First incomplete requested source step: {first_incomplete}")
16528 print(
16529 "[INFO] Closest complete source steps: "
16530 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
16531 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
16532 )
16533 elif post_plan['skip_reason'] == 'nothing-available-yet':
16534 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
16535 first_incomplete = diagnostic.get('first_incomplete_step')
16536 if first_incomplete is not None:
16537 print(
16538 f"[INFO] First requested source step {first_incomplete} is incomplete; "
16539 "skipping postprocessor launch for now."
16540 )
16541 print(
16542 "[INFO] Closest complete source steps: "
16543 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
16544 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
16545 )
16546 missing_files = diagnostic.get('missing_files_for_first_incomplete_step') or []
16547 if missing_files:
16548 print(f"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
16549 else:
16550 print("[INFO] No fully available source steps exist yet in the requested window; skipping postprocessor launch for now.")
16551 else:
16552 print(
16553 f"[INFO] Effective post window: {post_plan['effective_start_step']}..{post_plan['effective_end_step']} "
16554 f"(stride {post_plan['step_interval']})"
16555 )
16556
16557 post_effective_cfg = post_plan['effective_post_cfg']
16558 post_io_cfg = post_effective_cfg.get('io', {})
16559 try:
16560 output_dir_rel = post_io_cfg['output_directory']
16561 output_prefix = post_io_cfg['output_filename_prefix']
16562 except KeyError as e:
16563 print(f"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
16564 sys.exit(1)
16565
16566 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
16567 os.makedirs(output_dir_abs, exist_ok=True)
16568 print(f"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
16569 statistics_output_paths = get_post_statistics_output_artifacts(post_effective_cfg, run_dir, monitor_cfg)
16570 for stats_path in statistics_output_paths:
16571 print(f"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
16572
16573 # Spectra run in the conductor rather than in the submitted job, so they can
16574 # only be measured when this invocation is the one doing the work. Under a
16575 # scheduler, with --no-submit, or before the solver has written anything,
16576 # the sources do not exist yet and the measurement is deferred instead.
16577 spectra_execute_now = (
16578 not cluster_mode
16579 and not args.no_submit
16580 and not post_plan['source_frontier_deferred']
16581 )
16582 if 'spectra' in post_stages and spectra_execute_now:
16583 spectra_summary = run_post_spectra_stage(
16584 run_dir,
16585 post_effective_cfg,
16586 monitor_cfg,
16587 post_plan['source_data_directory'],
16588 range(post_plan['effective_start_step'],
16589 post_plan['effective_end_step'] + 1,
16590 post_plan['step_interval']),
16591 )
16592 for artifact in spectra_summary['artifacts']:
16593 print(f"[INFO] Spectra output: {os.path.relpath(artifact)}")
16594 elif 'spectra' in post_stages and not (cluster_mode and 'fields' in post_stages):
16595 # Under a scheduler with the field stage selected the batch script runs
16596 # the spectra step itself, so no instruction is printed for that case.
16597 reason = ("the solver stage has not produced output yet"
16598 if post_plan['source_frontier_deferred'] else
16599 "this invocation only stages the post job")
16600 print(f"[INFO] Spectra deferred: {reason}. Measure them once the run has "
16601 f"checkpoints with:")
16602 print(f"[INFO] picurv run --post-process --only spectra "
16603 f"--run-dir {os.path.relpath(run_dir)} --post {args.post}")
16604
16605 if 'fields' not in post_stages:
16606 # --only selected a subset that excludes the field post-processor.
16607 print("[INFO] Skipping the field post-processor (--only "
16608 f"{','.join(sorted(post_stages))}).")
16609 stages_completed.append('post-process')
16610 else:
16611 source_files_post = {'Case': case_path, 'Post-Profile': args.post}
16612 post_recipe_file = generate_post_recipe_file(run_dir, run_id, post_effective_cfg, source_files_post, monitor_cfg)
16613
16614 post_exe = resolve_runtime_executable("postprocessor")
16615 post_args = build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor") + [
16616 "-control_file",
16617 solver_control_path,
16618 "-postprocessing_config_file",
16619 post_recipe_file,
16620 ]
16621 if cluster_mode:
16622 scheduler_dir = os.path.join(run_dir, "scheduler")
16623 os.makedirs(scheduler_dir, exist_ok=True)
16624 post_script = os.path.join(scheduler_dir, "post.sbatch")
16625 post_log = os.path.join(scheduler_dir, "post_%j.out")
16626 post_err = os.path.join(scheduler_dir, "post_%j.err")
16627 post_cluster_cfg = cluster_cfg
16628 raw_post_cmd = build_cluster_launch_command(
16629 post_cluster_cfg,
16630 post_exe,
16631 post_args,
16632 config_search_anchor=case_path,
16633 extra_search_anchors=[cluster_path],
16634 force_num_procs=post_num_procs_effective,
16635 )
16636 post_cmd, _ = build_post_locked_command(
16637 run_dir,
16638 post_plan['recipe_fingerprint'],
16639 raw_post_cmd,
16640 create_wrapper=True,
16641 )
16642 spectra_follow = []
16643 if 'spectra' in post_stages:
16644 follow = build_spectra_follow_command(run_dir, args.post, post_effective_cfg)
16645 if follow:
16646 spectra_follow = [follow]
16647 print("[INFO] Spectra will be measured in the post job, after "
16648 "the field post-processor completes.")
16650 post_script,
16651 f"{run_id}_post",
16652 post_cluster_cfg,
16653 post_cmd,
16654 run_dir,
16655 post_log,
16656 post_err,
16657 env_vars={"LOG_LEVEL": monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()},
16658 follow_commands=spectra_follow,
16659 )
16660 submission_meta["stages"]["post-process"] = {
16661 "script": post_script,
16662 "submitted": False,
16663 "num_procs_effective": post_num_procs_effective,
16664 "resume_recipe_match": post_plan['resume_recipe_match'],
16665 "resume_bootstrapped": post_plan['resume_bootstrapped'],
16666 "resume_match_source": post_plan['resume_match_source'],
16667 "effective_start_step": post_plan['effective_start_step'],
16668 "effective_end_step": post_plan['effective_end_step'],
16669 "completed_frontier_step": post_plan['completed_frontier_step'],
16670 "source_frontier_step": post_plan['source_frontier_step'],
16671 "source_frontier_deferred": post_plan['source_frontier_deferred'],
16672 "recipe_fingerprint": post_plan['recipe_fingerprint'],
16673 }
16674 print(f"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
16675
16676 if not args.no_submit:
16677 dependency_job = None
16678 if args.solve:
16679 dependency_job = submission_meta.get("stages", {}).get("solve", {}).get("job_id")
16680 submit_info = submit_sbatch(post_script, dependency=dependency_job)
16681 submission_meta["stages"]["post-process"].update(submit_info)
16682 submission_meta["stages"]["post-process"]["submitted"] = True
16683 if dependency_job:
16684 submission_meta["stages"]["post-process"]["dependency"] = f"afterok:{dependency_job}"
16685 print(f"[SUCCESS] Submitted post job: {submit_info['job_id']}")
16686 stages_completed.append('post-process')
16687 else:
16688 raw_command = build_local_launch_command(
16689 post_exe,
16690 post_args,
16691 post_num_procs_effective,
16692 config_search_anchor=case_path,
16693 allow_single_rank_launcher_override=True,
16694 force_num_procs=post_num_procs_effective,
16695 )
16696 command, _ = build_post_locked_command(
16697 run_dir,
16698 post_plan['recipe_fingerprint'],
16699 raw_command,
16700 create_wrapper=True,
16701 )
16702 post_log = os.path.join("scheduler", f"{run_id}_{output_prefix}.log")
16703 submission_meta["stages"]["post-process"] = {
16704 "command": command,
16705 "command_string": format_command_for_display(command),
16706 "log_file": post_log,
16707 "submitted": False,
16708 "num_procs_effective": post_num_procs_effective,
16709 "resume_recipe_match": post_plan['resume_recipe_match'],
16710 "resume_bootstrapped": post_plan['resume_bootstrapped'],
16711 "resume_match_source": post_plan['resume_match_source'],
16712 "effective_start_step": post_plan['effective_start_step'],
16713 "effective_end_step": post_plan['effective_end_step'],
16714 "completed_frontier_step": post_plan['completed_frontier_step'],
16715 "source_frontier_step": post_plan['source_frontier_step'],
16716 "source_frontier_deferred": post_plan['source_frontier_deferred'],
16717 "recipe_fingerprint": post_plan['recipe_fingerprint'],
16718 }
16719 if args.no_submit:
16720 print(f"[SUCCESS] Staged local post command: {post_log}")
16721 else:
16722 execute_command(command, run_dir, post_log, monitor_cfg)
16723 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['effective_end_step'])
16724 submission_meta["stages"]["post-process"]["submitted"] = True
16725 submission_meta["stages"]["post-process"]["executed"] = True
16726 submission_meta["stages"]["post-process"]["completed_at"] = datetime.now().isoformat()
16727 stages_completed.append('post-process')
16728
16729 if run_dir:
16730 workspace_root = find_workspace_root(run_dir)
16731 manifest_inputs = {}
16732 if args.solve:
16733 manifest_inputs["case"] = _relative_to_workspace(args.case, workspace_root)
16734 manifest_inputs["solver"] = _relative_to_workspace(args.solver, workspace_root)
16735 manifest_inputs["monitor"] = _relative_to_workspace(args.monitor, workspace_root)
16736 if args.post_process:
16737 manifest_inputs["post"] = _relative_to_workspace(args.post, workspace_root)
16738 if cluster_mode:
16739 manifest_inputs["cluster"] = _relative_to_workspace(cluster_path, workspace_root)
16740 if submission_meta.get("stages"):
16741 write_json_file(os.path.join(run_dir, "scheduler", "submission.json"), submission_meta)
16742 asset_lock = read_yaml_file(os.path.join(run_dir, "inputs", "assets.lock.yml")) \
16743 if os.path.isfile(os.path.join(run_dir, "inputs", "assets.lock.yml")) else {}
16744 manifest = build_run_manifest(
16745 run_dir,
16746 run_id,
16747 workspace_root=workspace_root,
16748 launch_mode="slurm" if cluster_mode else "local",
16749 num_procs=solver_num_procs_effective,
16750 post_num_procs=post_num_procs_effective,
16751 stages_requested={"solve": bool(args.solve), "post_process": bool(args.post_process)},
16752 stages_completed=stages_completed,
16753 inputs=manifest_inputs,
16754 asset_lock=asset_lock,
16755 submission=submission_meta,
16756 lineage=run_lineage,
16757 )
16758 write_json_file(os.path.join(run_dir, "manifest.json"), manifest)
16759
16760 if stages_completed:
16761 elapsed = time.time() - workflow_start
16762 mins, secs = divmod(int(elapsed), 60)
16763 hrs, mins = divmod(mins, 60)
16764 if hrs > 0:
16765 time_str = f"{hrs}h {mins}m {secs}s"
16766 elif mins > 0:
16767 time_str = f"{mins}m {secs}s"
16768 else:
16769 time_str = f"{secs}s"
16770
16771 print("\n" + "=" * 60)
16772 print(" RUN SUMMARY")
16773 print("=" * 60)
16774 print(f" Run ID : {run_id}")
16775 print(f" Run directory : {os.path.relpath(run_dir)}")
16776 print(f" Wall-clock : {time_str}")
16777 print(f" Stages : {', '.join(stages_completed)}")
16778 print(f" Launch mode : {'slurm' if cluster_mode else 'local'}")
16779 if args.solve:
16780 print(f" Solver MPI procs: {solver_num_procs_effective}")
16781 if args.post_process:
16782 print(f" Post MPI procs : {post_num_procs_effective}")
16783 if args.solve and configs:
16784 total_steps = configs['case'].get('run_control', {}).get('total_steps', '?')
16785 result_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS['output'])
16786 print(f" Steps run : {total_steps}")
16787 print(f" Solver output : {os.path.relpath(result_dir)}")
16788 if 'post-process' in stages_completed and output_dir_abs:
16789 print(f" Post output : {os.path.relpath(output_dir_abs)}")
16790 for stats_path in statistics_output_paths:
16791 print(f" Stats output : {os.path.relpath(stats_path)}")
16792 # Report the configured log directory, not the default. An authorized external
16793 # path is exactly the case where the two differ, and it is the case where the
16794 # reader most needs to know where the logs actually went.
16795 log_display = os.path.join(run_dir, CANONICAL_RUN_PATHS['logs'])
16796 relative_log = os.path.relpath(log_display)
16797 # A relative path that climbs out of the working directory is harder to read
16798 # than the absolute one it stands for.
16799 print(f" Logs : "
16800 f"{log_display if relative_log.startswith('..') else relative_log}")
16801 if cluster_mode or submission_meta.get("stages"):
16802 submission_file = os.path.join(run_dir, "scheduler", "submission.json")
16803 print(f" Submission meta: {os.path.relpath(submission_file)}")
16804 print("=" * 60)
16805
16806
16807def parse_case_index_tsv(tsv_path: str) -> list:
16808 """!
16809 @brief Parse a case_index.tsv file back into a list of case entry dicts.
16810 @param[in] tsv_path Path to the case_index.tsv file.
16811 @return List of dicts with keys: index, case_id, run_dir, control_file,
16812 post_recipe_file, log_level, post_prefix.
16813 """
16814 entries = []
16815 with open(tsv_path) as f:
16816 for line in f:
16817 line = line.strip()
16818 if not line:
16819 continue
16820 parts = line.split("\t")
16821 entries.append({
16822 "index": int(parts[0]),
16823 "case_id": parts[1],
16824 "run_dir": parts[2],
16825 "control_file": parts[3],
16826 "post_recipe_file": parts[4],
16827 "log_level": parts[5],
16828 "post_prefix": parts[6],
16829 })
16830 return entries
16831
16832
16834 """!
16835 @brief Study/sweep orchestration using Slurm job arrays.
16836 @param[in] args Command-line style argument list supplied to the function.
16837 """
16838 study_path = os.path.abspath(args.study)
16839 cluster_path = os.path.abspath(args.cluster)
16840 workspace_root = find_workspace_root(study_path, cluster_path, os.getcwd())
16841 if workspace_root:
16842 enforce_workspace_version(workspace_root)
16843 enforce_reproducibility_policy(workspace_root)
16844
16845 study_cfg = read_yaml_file(study_path)
16846 cluster_cfg = read_yaml_file(cluster_path)
16847 validate_study_config(study_cfg, study_path)
16848 validate_cluster_config(cluster_cfg, cluster_path)
16849
16850 study_name = case_run_label(study_cfg, study_path)
16851 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
16852 study_id = f"{study_name}_{timestamp}"
16853 study_dir = os.path.join(workspace_artifact_root(workspace_root, "studies"), study_id)
16854 cases_dir = os.path.join(study_dir, "cases")
16855 scheduler_dir = os.path.join(study_dir, "scheduler")
16856 results_dir = os.path.join(study_dir, "output", "analysis")
16857 for path in [cases_dir, scheduler_dir, results_dir, os.path.join(study_dir, "logs")]:
16858 os.makedirs(path, exist_ok=True)
16859
16860 print(f"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
16861 base_cfgs = study_cfg["base_configs"]
16862 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
16863 base_snapshot_dir = os.path.join(study_dir, "base_configs")
16864 os.makedirs(base_snapshot_dir, exist_ok=True)
16865 portable_study_cfg = copy.deepcopy(study_cfg)
16866 portable_study_cfg["base_configs"] = {}
16867 for role in ("case", "solver", "monitor", "post"):
16868 snapshot_name = f"{role}.yml"
16869 snapshot_path = os.path.join(base_snapshot_dir, snapshot_name)
16870 shutil.copy2(base_paths[role], snapshot_path)
16871 portable_study_cfg["base_configs"][role] = os.path.join("base_configs", snapshot_name)
16872 shutil.copy2(study_path, os.path.join(study_dir, "study.source.yml"))
16873 write_yaml_file(os.path.join(study_dir, "study.yml"), portable_study_cfg)
16874 shutil.copy2(cluster_path, os.path.join(study_dir, "cluster.yml"))
16875
16876 base_case = read_yaml_file(base_paths["case"])
16877 base_solver = read_yaml_file(base_paths["solver"])
16878 base_monitor = read_yaml_file(base_paths["monitor"])
16879 base_post = read_yaml_file(base_paths["post"])
16880 validate_simulation_configs(base_case, base_solver, base_monitor, base_paths["case"], base_paths["solver"], base_paths["monitor"])
16881 validate_post_config(base_post, base_paths["post"], base_monitor, base_case)
16882
16883 combinations = expand_study_parameter_combinations(study_cfg)
16884 if not combinations:
16885 print("[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
16886 sys.exit(1)
16887 print(f"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
16888
16889 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
16890 case_entries = []
16891 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
16892
16893 for idx, combo in enumerate(combinations):
16894 case_id = f"case_{idx:04d}"
16895 run_dir = os.path.join(cases_dir, case_id)
16896 config_dir = os.path.join(run_dir, "config")
16897 ensure_run_layout(run_dir)
16898
16899 case_cfg = copy.deepcopy(base_case)
16900 solver_cfg = copy.deepcopy(base_solver)
16901 monitor_cfg = copy.deepcopy(base_monitor)
16902 post_cfg = copy.deepcopy(base_post)
16903 target_map = {"case": case_cfg, "solver": solver_cfg, "monitor": monitor_cfg, "post": post_cfg}
16904 for full_key, value in combo.items():
16905 root, nested = full_key.split(".", 1)
16906 _deep_set(target_map[root], nested, value)
16907
16908 # Preserve file-based/grid-gen workflows when study cases are materialized
16909 # into new directories by rewriting external paths as absolute.
16910 absolutize_case_external_paths(case_cfg, base_paths["case"])
16911
16912 case_path = os.path.join(config_dir, "case.yml")
16913 solver_path = os.path.join(config_dir, "solver.yml")
16914 monitor_path = os.path.join(config_dir, "monitor.yml")
16915 post_path = os.path.join(config_dir, "post.yml")
16916 write_yaml_file(case_path, case_cfg)
16917 write_yaml_file(solver_path, solver_cfg)
16918 write_yaml_file(monitor_path, monitor_cfg)
16919 write_yaml_file(post_path, post_cfg)
16920 write_json_file(os.path.join(config_dir, "active.json"), {
16921 "schema_version": 1,
16922 "revision": "initial",
16923 "updated_at": datetime.now().astimezone().isoformat(),
16924 "files": {
16925 "case": "config/case.yml",
16926 "solver": "config/solver.yml",
16927 "monitor": "config/monitor.yml",
16928 },
16929 })
16930
16931 validate_simulation_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
16932 validate_post_config(post_cfg, post_path, monitor_cfg, case_cfg)
16933
16934 asset_lock = materialize_run_assets(run_dir, case_cfg, case_path)
16935
16936 source_files = {'Case': case_path, 'Solver': solver_path, 'Monitor': monitor_path}
16937 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
16938 configs = {
16939 "case": case_cfg, "case_path": case_path,
16940 "solver": solver_cfg, "solver_path": solver_path,
16941 "monitor": monitor_cfg, "monitor_path": monitor_path,
16942 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
16943 }
16944 control_file = generate_solver_control_file(run_dir, case_id, configs, cluster_tasks, monitor_files)
16946 run_dir,
16947 [control_file, monitor_files.get("whitelist"), monitor_files.get("profile"),
16948 *glob.glob(os.path.join(config_dir, "bcs*.run"))],
16949 )
16950
16951 source_dir = resolve_post_source_directory(run_dir, monitor_cfg, post_cfg, strict=False)
16952 if not isinstance(post_cfg.get('source_data'), dict):
16953 post_cfg['source_data'] = {}
16954 post_cfg['source_data']['directory'] = source_dir
16955 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
16956 for relative in (
16957 post_cfg["_picurv_paths"]["visualization"],
16958 post_cfg["_picurv_paths"]["statistics"],
16959 post_cfg["_picurv_paths"]["spectra"],
16960 ):
16961 os.makedirs(os.path.join(run_dir, relative), exist_ok=True)
16962 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix", "post")
16963 post_recipe = generate_post_recipe_file(run_dir, case_id, post_cfg, {'Case': case_path, 'Post-Profile': post_path}, monitor_cfg)
16964
16965 case_entries.append({
16966 "index": idx,
16967 "case_id": case_id,
16968 "run_dir": os.path.abspath(run_dir),
16969 "control_file": control_file,
16970 "post_recipe_file": post_recipe,
16971 "log_level": str(monitor_cfg.get("logging", {}).get("verbosity", "INFO")).upper(),
16972 "post_prefix": output_prefix,
16973 "solve_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "Solver")),
16974 "post_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor")),
16975 "parameters": combo,
16976 })
16978 os.path.join(run_dir, "manifest.json"),
16980 run_dir, case_id, workspace_root=workspace_root, launch_mode="slurm",
16981 artifact_type="study-case", study_id=study_id, case_id=case_id,
16982 num_procs=cluster_tasks, post_num_procs=cluster_tasks,
16983 stages_requested={"solve": True, "post_process": True},
16984 inputs={
16985 "case": _relative_to_workspace(case_path, workspace_root),
16986 "solver": _relative_to_workspace(solver_path, workspace_root),
16987 "monitor": _relative_to_workspace(monitor_path, workspace_root),
16988 "post": _relative_to_workspace(post_path, workspace_root),
16989 },
16990 asset_lock=asset_lock,
16991 ),
16992 )
16993
16994 with open(case_index_file, "w") as f:
16995 for entry in case_entries:
16996 f.write(
16997 "\t".join(
16998 [
16999 str(entry["index"]),
17000 entry["case_id"],
17001 entry["run_dir"],
17002 entry["control_file"],
17003 entry["post_recipe_file"],
17004 entry["log_level"],
17005 entry["post_prefix"],
17006 entry["solve_diagnostic_args"],
17007 entry["post_diagnostic_args"],
17008 ]
17009 ) + "\n"
17010 )
17011 print(f"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
17012
17013 max_idx = len(case_entries) - 1
17014 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
17015 array_spec = f"0-{max_idx}"
17016 if max_conc:
17017 array_spec = f"{array_spec}%{max_conc}"
17018
17019 solver_exe = resolve_runtime_executable("simulator")
17020 post_exe = resolve_runtime_executable("postprocessor")
17021 solver_array_script = os.path.join(scheduler_dir, "solver_array.sbatch")
17022 post_array_script = os.path.join(scheduler_dir, "post_array.sbatch")
17024 solver_array_script,
17025 f"{study_id}_solve",
17026 cluster_cfg,
17027 array_spec,
17028 case_index_file,
17029 "solve",
17030 solver_exe,
17031 post_exe,
17032 os.path.join(scheduler_dir, "solver_%A_%a.out"),
17033 os.path.join(scheduler_dir, "solver_%A_%a.err")
17034 )
17036 post_array_script,
17037 f"{study_id}_post",
17038 cluster_cfg,
17039 array_spec,
17040 case_index_file,
17041 "post",
17042 solver_exe,
17043 post_exe,
17044 os.path.join(scheduler_dir, "post_%A_%a.out"),
17045 os.path.join(scheduler_dir, "post_%A_%a.err")
17046 )
17047 print(f"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
17048
17049 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
17050 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_aggregate.sbatch")
17052 metrics_aggregate_script,
17053 f"{study_id}_metrics",
17054 cluster_cfg,
17055 study_dir,
17056 picurv_path,
17057 )
17058 print(f"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
17059
17060 submission = {
17061 "launch_mode": "slurm",
17062 "study_id": study_id,
17063 "solver_array": {"script": solver_array_script, "submitted": False},
17064 "post_array": {"script": post_array_script, "submitted": False},
17065 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
17066 "no_submit": bool(args.no_submit),
17067 }
17068 if not args.no_submit:
17069 solver_submit = submit_sbatch(solver_array_script)
17070 submission["solver_array"].update(solver_submit)
17071 submission["solver_array"]["submitted"] = True
17072 post_submit = submit_sbatch(post_array_script, dependency=solver_submit["job_id"])
17073 submission["post_array"].update(post_submit)
17074 submission["post_array"]["submitted"] = True
17075 submission["post_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
17076 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
17077 submission["metrics_aggregate"].update(metrics_submit)
17078 submission["metrics_aggregate"]["submitted"] = True
17079 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
17080 print(f"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
17081 print(f"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
17082 print(f"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
17083
17084 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
17085 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
17086
17087 summary = {
17088 "schema_version": 2,
17089 "artifact_type": "study",
17090 "study_id": study_id,
17091 "created_at": datetime.now().isoformat(),
17092 "software": dict(PICURV_BUILD),
17093 "study_type": study_cfg.get("study_type"),
17094 "num_cases": len(case_entries),
17095 "paths": {
17096 "study_dir": study_dir,
17097 "case_index": case_index_file,
17098 "solver_array_script": solver_array_script,
17099 "post_array_script": post_array_script,
17100 "metrics_table": metrics_csv,
17101 "plots_dir": os.path.join(results_dir, "plots"),
17102 },
17103 "submission": submission,
17104 }
17105 write_json_file(os.path.join(scheduler_dir, "submission.json"), submission)
17106 write_json_file(os.path.join(study_dir, "study_manifest.json"), summary)
17107 write_json_file(os.path.join(results_dir, "summary.json"), {"study_id": study_id, "metrics_csv": metrics_csv, "plots": plots})
17108
17109 print("\n" + "=" * 60)
17110 print(" STUDY SUMMARY")
17111 print("=" * 60)
17112 print(f" Study ID : {study_id}")
17113 print(f" Study directory : {os.path.relpath(study_dir)}")
17114 print(f" Cases generated : {len(case_entries)}")
17115 print(f" Array spec : {array_spec}")
17116 print(f" Solver script : {os.path.relpath(solver_array_script)}")
17117 print(f" Post script : {os.path.relpath(post_array_script)}")
17118 if metrics_csv:
17119 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
17120 if plots:
17121 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
17122 print("=" * 60)
17123
17124
17126 """!
17127 @brief Continue a partially-completed Slurm parameter sweep study.
17128 @details Detects incomplete cases, prepares them for continuation (updating
17129 start_step, populating restart directories, regenerating control files),
17130 and submits new solver/post/metrics Slurm jobs. If all cases are already
17131 complete, performs metrics aggregation automatically.
17132 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
17133 """
17134 study_dir = os.path.abspath(args.study_dir)
17135 manifest_path = os.path.join(study_dir, "study_manifest.json")
17136 if not os.path.isfile(manifest_path):
17137 print(f"[FATAL] Study manifest not found: {manifest_path}", file=sys.stderr)
17138 sys.exit(1)
17139 manifest = _read_json_if_exists(manifest_path)
17140 study_id = manifest["study_id"]
17141
17142 study_path = os.path.join(study_dir, "study.yml")
17143 cluster_path = os.path.abspath(args.cluster) if args.cluster else os.path.join(study_dir, "cluster.yml")
17144 study_cfg = read_yaml_file(study_path)
17145 cluster_cfg = read_yaml_file(cluster_path)
17146 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
17147 validate_cluster_config(cluster_cfg, cluster_path)
17148
17149 if args.cluster:
17150 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir, "cluster.yml"))
17151 print(f"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
17152
17153 scheduler_dir = os.path.join(study_dir, "scheduler")
17154 cases_dir = os.path.join(study_dir, "cases")
17155 results_dir = os.path.join(study_dir, "output", "analysis")
17156 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
17157 if not os.path.isfile(case_index_file):
17158 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
17159 sys.exit(1)
17160
17161 parsed_entries = parse_case_index_tsv(case_index_file)
17162
17163 cold_cases = cold_study_members(study_dir)
17164 if cold_cases and getattr(args, "auto_fetch", False):
17165 print(
17166 "[INFO] --auto-fetch: restoring cold-storage member(s) before continuing: "
17167 + ", ".join(cold_cases)
17168 )
17169 try:
17170 restore_cold_study_members(study_dir, cold_cases)
17171 except StorageError as exc:
17172 print(f"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
17173 sys.exit(1)
17174 cold_cases = cold_study_members(study_dir)
17175 if cold_cases:
17176 print(
17177 "[FATAL] Study continuation requires payload from cold-storage member(s): "
17178 + ", ".join(cold_cases),
17179 file=sys.stderr,
17180 )
17181 for case_id in cold_cases:
17182 state = storage_state_summary(os.path.join(cases_dir, case_id))
17183 print(
17184 f" Restore {case_id} with: picurv storage restore --archive-id "
17185 f"{state.get('archive_id') or '<archive-id>'}",
17186 file=sys.stderr,
17187 )
17188 print(
17189 " Or pass --auto-fetch to restore them automatically.",
17190 file=sys.stderr,
17191 )
17192 sys.exit(1)
17193
17194 base_cfgs = study_cfg["base_configs"]
17195 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
17196 base_case = read_yaml_file(base_paths["case"])
17197
17198 combinations = expand_study_parameter_combinations(study_cfg)
17199 if len(combinations) != len(parsed_entries):
17200 print(
17201 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
17202 f"case_index.tsv ({len(parsed_entries)} entries).",
17203 file=sys.stderr,
17204 )
17205 sys.exit(1)
17206
17207 print(f"\n[INFO] Study: {study_id}")
17208 print(f"[INFO] Scanning {len(combinations)} case(s) for completion status...")
17209
17210 incomplete_indices = []
17211 all_case_entries = []
17212 for idx, combo in enumerate(combinations):
17213 case_id = f"case_{idx:04d}"
17214 entry = parsed_entries[idx]
17215 entry["parameters"] = combo
17216 run_dir = entry["run_dir"]
17217
17218 effective_case = copy.deepcopy(base_case)
17219 for full_key, value in combo.items():
17220 root, nested = full_key.split(".", 1)
17221 if root == "case":
17222 _deep_set(effective_case, nested, value)
17223 try:
17224 eff_start = int(effective_case.get("run_control", {}).get("start_step", 0) or 0)
17225 except (TypeError, ValueError):
17226 eff_start = 0
17227 eff_total = int(effective_case["run_control"]["total_steps"])
17228 target = eff_start + eff_total
17229
17230 monitor_cfg = read_yaml_file(os.path.join(run_dir, "config", "monitor.yml"))
17231 status = detect_case_completion_status(run_dir, monitor_cfg, target)
17232 entry["_status"] = status
17233
17234 if status["status"] == "complete":
17235 print(f" {case_id}: complete (step {status['last_step']}/{target})")
17236 elif status["status"] == "partial":
17237 print(f" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
17238 incomplete_indices.append(idx)
17239 else:
17240 print(f" {case_id}: no checkpoint — will re-run from scratch")
17241 incomplete_indices.append(idx)
17242
17243 all_case_entries.append(entry)
17244
17245 if not incomplete_indices:
17246 print("\n[INFO] All cases are complete. Running metrics aggregation...")
17247 metrics_csv = aggregate_study_metrics(study_cfg, all_case_entries, results_dir)
17248 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
17249 print("\n" + "=" * 60)
17250 print(" STUDY CONTINUATION SUMMARY")
17251 print("=" * 60)
17252 print(f" Study ID : {study_id}")
17253 print(f" Status : ALL COMPLETE")
17254 if metrics_csv:
17255 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
17256 if plots:
17257 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
17258 print("=" * 60)
17259 return
17260
17261 print(f"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
17262
17263 skipped = []
17264 for idx in incomplete_indices:
17265 entry = all_case_entries[idx]
17266 status = entry["_status"]
17267 if status["status"] == "partial":
17269 entry["run_dir"], entry["case_id"],
17270 status["last_step"], status["target_step"],
17271 cluster_cfg,
17272 )
17273 elif status["status"] == "empty":
17274 print(f"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
17275
17276 solver_array_spec = ",".join(str(i) for i in incomplete_indices)
17277 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
17278 if max_conc:
17279 solver_array_spec = f"{solver_array_spec}%{max_conc}"
17280
17281 max_idx = len(combinations) - 1
17282 post_array_spec = f"0-{max_idx}"
17283 if max_conc:
17284 post_array_spec = f"{post_array_spec}%{max_conc}"
17285
17286 solver_exe = resolve_runtime_executable("simulator")
17287 post_exe = resolve_runtime_executable("postprocessor")
17288
17289 solver_continue_script = os.path.join(scheduler_dir, "solver_continue_array.sbatch")
17290 post_continue_script = os.path.join(scheduler_dir, "post_continue_array.sbatch")
17292 solver_continue_script,
17293 f"{study_id}_solve_cont",
17294 cluster_cfg,
17295 solver_array_spec,
17296 case_index_file,
17297 "solve",
17298 solver_exe, post_exe,
17299 os.path.join(scheduler_dir, "solver_cont_%A_%a.out"),
17300 os.path.join(scheduler_dir, "solver_cont_%A_%a.err"),
17301 )
17303 post_continue_script,
17304 f"{study_id}_post_cont",
17305 cluster_cfg,
17306 post_array_spec,
17307 case_index_file,
17308 "post",
17309 solver_exe, post_exe,
17310 os.path.join(scheduler_dir, "post_cont_%A_%a.out"),
17311 os.path.join(scheduler_dir, "post_cont_%A_%a.err"),
17312 )
17313
17314 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
17315 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_continue_aggregate.sbatch")
17317 metrics_aggregate_script,
17318 f"{study_id}_metrics_cont",
17319 cluster_cfg,
17320 study_dir,
17321 picurv_path,
17322 )
17323 print(f"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
17324
17325 submission = {
17326 "launch_mode": "slurm",
17327 "study_id": study_id,
17328 "continuation": True,
17329 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
17330 "solver_continue_array": {"script": solver_continue_script, "submitted": False},
17331 "post_continue_array": {"script": post_continue_script, "submitted": False},
17332 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
17333 "no_submit": bool(args.no_submit),
17334 }
17335 if not args.no_submit:
17336 solver_submit = submit_sbatch(solver_continue_script)
17337 submission["solver_continue_array"].update(solver_submit)
17338 submission["solver_continue_array"]["submitted"] = True
17339 post_submit = submit_sbatch(post_continue_script, dependency=solver_submit["job_id"])
17340 submission["post_continue_array"].update(post_submit)
17341 submission["post_continue_array"]["submitted"] = True
17342 submission["post_continue_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
17343 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
17344 submission["metrics_aggregate"].update(metrics_submit)
17345 submission["metrics_aggregate"]["submitted"] = True
17346 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
17347 print(f"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
17348 print(f"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
17349 print(f"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
17350
17351 write_json_file(os.path.join(scheduler_dir, "submission_continue.json"), submission)
17352
17353 manifest["continuation"] = {
17354 "continued_at": datetime.now().isoformat(),
17355 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
17356 "submission": submission,
17357 }
17358 write_json_file(manifest_path, manifest)
17359
17360 print("\n" + "=" * 60)
17361 print(" STUDY CONTINUATION SUMMARY")
17362 print("=" * 60)
17363 print(f" Study ID : {study_id}")
17364 print(f" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
17365 print(f" Solver array spec : {solver_array_spec}")
17366 print(f" Post array spec : {post_array_spec}")
17367 print(f" Solver script : {os.path.relpath(solver_continue_script)}")
17368 print(f" Post script : {os.path.relpath(post_continue_script)}")
17369 print(f" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
17370 if not args.no_submit:
17371 print(f" [Metrics aggregation will run automatically after post-processing]")
17372 else:
17373 print(f" [--no-submit] Scripts generated but not submitted.")
17374 print(f" After manual submission and completion, run:")
17375 print(f" picurv sweep --reaggregate --study-dir {os.path.relpath(study_dir)}")
17376 print("=" * 60)
17377
17378
17380 """!
17381 @brief Re-run metrics aggregation and plot generation for an existing study.
17382 @param[in] args Parsed CLI arguments with study_dir.
17383 """
17384 study_dir = os.path.abspath(args.study_dir)
17385 study_path = os.path.join(study_dir, "study.yml")
17386 if not os.path.isfile(study_path):
17387 print(f"[FATAL] Study config not found: {study_path}", file=sys.stderr)
17388 sys.exit(1)
17389 study_cfg = read_yaml_file(study_path)
17390 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
17391
17392 case_index_file = os.path.join(study_dir, "scheduler", "case_index.tsv")
17393 if not os.path.isfile(case_index_file):
17394 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
17395 sys.exit(1)
17396
17397 parsed_entries = parse_case_index_tsv(case_index_file)
17398 cold_cases = cold_study_members(study_dir)
17399 if cold_cases and getattr(args, "auto_fetch", False):
17400 print(
17401 "[INFO] --auto-fetch: restoring cold-storage member(s) before aggregating: "
17402 + ", ".join(cold_cases)
17403 )
17404 try:
17405 restore_cold_study_members(study_dir, cold_cases)
17406 except StorageError as exc:
17407 print(f"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
17408 sys.exit(1)
17409 cold_cases = cold_study_members(study_dir)
17410 if cold_cases:
17411 print(
17412 "[INFO] Cold-storage member(s) cannot be re-measured; their previously "
17413 "aggregated values are carried forward: " + ", ".join(cold_cases)
17414 )
17415 combinations = expand_study_parameter_combinations(study_cfg)
17416 if len(combinations) != len(parsed_entries):
17417 print(
17418 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
17419 f"case_index.tsv ({len(parsed_entries)} entries).",
17420 file=sys.stderr,
17421 )
17422 sys.exit(1)
17423
17424 case_entries = []
17425 for idx, combo in enumerate(combinations):
17426 entry = parsed_entries[idx]
17427 entry["parameters"] = combo
17428 case_entries.append(entry)
17429
17430 results_dir = os.path.join(study_dir, "output", "analysis")
17431 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
17432 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
17433
17434 print("\n" + "=" * 60)
17435 print(" REAGGREGATION SUMMARY")
17436 print("=" * 60)
17437 if metrics_csv:
17438 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
17439 if plots:
17440 print(f" Plots generated : {len(plots)}")
17441 print("=" * 60)
17442
17443
17444_SUMMARY_NUMERIC_RE = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
17445
17446
17447def _read_yaml_if_exists(filepath: str):
17448 """!
17449 @brief Read YAML when present, otherwise return None.
17450 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
17451 @return Value returned by `_read_yaml_if_exists()`.
17452 """
17453 if not filepath or not os.path.isfile(filepath):
17454 return None
17455 try:
17456 with open(filepath, "r", encoding="utf-8") as f:
17457 return yaml.safe_load(f)
17458 except yaml.YAMLError:
17459 return None
17460
17461
17462def _read_json_if_exists(filepath: str):
17463 """!
17464 @brief Read JSON when present, otherwise return None.
17465 @param[in] filepath Argument passed to `_read_json_if_exists()`.
17466 @return Value returned by `_read_json_if_exists()`.
17467 """
17468 if not filepath or not os.path.isfile(filepath):
17469 return None
17470 with open(filepath, "r", encoding="utf-8") as f:
17471 return json.load(f)
17472
17473
17475 """!
17476 @brief Best-effort integer parsing for summary extraction.
17477 @param[in] value Argument passed to `_parse_int_loose()`.
17478 @return Value returned by `_parse_int_loose()`.
17479 """
17480 if value is None:
17481 return None
17482 text = str(value).strip()
17483 if not text:
17484 return None
17485 try:
17486 return int(text)
17487 except ValueError:
17488 try:
17489 return int(float(text))
17490 except ValueError:
17491 return None
17492
17493
17495 """!
17496 @brief Best-effort float parsing for summary extraction.
17497 @param[in] value Argument passed to `_parse_float_loose()`.
17498 @return Value returned by `_parse_float_loose()`.
17499 """
17500 if value is None:
17501 return None
17502 text = str(value).strip()
17503 if not text:
17504 return None
17505 try:
17506 return float(text)
17507 except ValueError:
17508 return None
17509
17510
17512 """!
17513 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
17514 @param[in] text Argument passed to `_extract_numeric_tuple()`.
17515 @return Value returned by `_extract_numeric_tuple()`.
17516 """
17517 if not text:
17518 return []
17519 return [float(token) for token in _SUMMARY_NUMERIC_RE.findall(text)]
17520
17521
17522def _build_summary_context(run_dir: str) -> dict:
17523 """!
17524 @brief Resolve run-local config and artifact paths for summarize.
17525 @param[in] run_dir Argument passed to `_build_summary_context()`.
17526 @return Value returned by `_build_summary_context()`.
17527 """
17528 run_dir = os.path.abspath(run_dir)
17529 if not os.path.isdir(run_dir):
17531 ERROR_CODE_CFG_FILE_NOT_FOUND,
17532 key="run_dir",
17533 file_path=run_dir,
17534 message="Run directory not found.",
17535 )
17536 sys.exit(1)
17537
17538 config_dir = os.path.join(run_dir, "config")
17539 config_paths = {
17540 "case": os.path.join(config_dir, "case.yml"),
17541 "solver": os.path.join(config_dir, "solver.yml"),
17542 "monitor": os.path.join(config_dir, "monitor.yml"),
17543 }
17544 monitor_cfg = _read_yaml_if_exists(config_paths["monitor"]) or {}
17545 case_cfg = _read_yaml_if_exists(config_paths["case"]) or {}
17546 solver_cfg = _read_yaml_if_exists(config_paths["solver"]) or {}
17547 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
17548
17549 io_cfg = monitor_cfg.get("io", {}) if isinstance(monitor_cfg, dict) else {}
17550 scheduler_dir = os.path.join(run_dir, "scheduler")
17551
17552 profiling_cfg = {"mode": "off", "functions": [], "timestep_file": "Profiling_Timestep_Summary.csv", "final_summary_enabled": True}
17553 if monitor_cfg:
17554 profiling_cfg = resolve_profiling_config(monitor_cfg)
17555
17556 particle_console_output_freq = None
17557 particle_log_interval = None
17558 if monitor_cfg:
17559 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
17560 particle_log_interval = io_cfg.get("particle_log_interval")
17561
17562 particle_count_cfg = None
17563 if case_cfg:
17564 particle_count_cfg = (
17565 case_cfg.get("models", {})
17566 .get("physics", {})
17567 .get("particles", {})
17568 .get("count")
17569 )
17570
17571 return {
17572 "run_dir": run_dir,
17573 "config_dir": config_dir,
17574 "log_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"]),
17575 "metrics_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS["metrics"]),
17576 "scheduler_dir": scheduler_dir,
17577 "monitor_cfg": monitor_cfg,
17578 "case_cfg": case_cfg,
17579 "solver_cfg": solver_cfg,
17580 "config_paths": config_paths,
17581 "manifest": manifest,
17582 "profiling_cfg": profiling_cfg,
17583 "particle_console_output_freq": particle_console_output_freq,
17584 "particle_log_interval": particle_log_interval,
17585 "particle_count_cfg": particle_count_cfg,
17586 }
17587
17588
17589def _require_summary_config(context: dict, name: str) -> dict:
17590 """!
17591 @brief Return one explicitly requested copied config or fail with a structured error.
17592 @param[in] context Summary context returned by `_build_summary_context()`.
17593 @param[in] name Config selector name.
17594 @return Parsed config mapping.
17595 """
17596 path = context["config_paths"][name]
17597 cfg = context.get(f"{name}_cfg")
17598 if not os.path.isfile(path):
17600 ERROR_CODE_CFG_FILE_NOT_FOUND,
17601 key=name,
17602 file_path=path,
17603 message=f"Copied run config '{name}.yml' was not found.",
17604 hint="Use a staged run directory containing the requested copied config.",
17605 )
17606 sys.exit(1)
17607 if not isinstance(cfg, dict) or not cfg:
17609 ERROR_CODE_CFG_INVALID_VALUE,
17610 key=name,
17611 file_path=path,
17612 message=f"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
17613 )
17614 sys.exit(1)
17615 return cfg
17616
17617
17618def _build_run_overview(context: dict) -> dict:
17619 """!
17620 @brief Build timestep-independent run metadata for summarize.
17621 @param[in] context Summary context returned by `_build_summary_context()`.
17622 @return Curated run metadata mapping.
17623 """
17624 manifest = context["manifest"]
17625 software = manifest.get("software") if isinstance(manifest.get("software"), dict) else {}
17626 return {
17627 "run_id": manifest.get("run_id", os.path.basename(context["run_dir"])),
17628 "run_dir": context["run_dir"],
17629 "created_at": manifest.get("created_at"),
17630 "launch_mode": manifest.get("launch_mode"),
17631 "release_version": software.get("release_version"),
17632 "build_id": software.get("build_id"),
17633 "git_commit": software.get("git_commit", manifest.get("git_commit")),
17634 "solver_num_procs": manifest.get("solver_num_procs", manifest.get("num_procs")),
17635 "post_num_procs": manifest.get("post_num_procs"),
17636 "stages_requested": manifest.get("stages_requested"),
17637 "stages_completed_or_submitted": manifest.get("stages_completed_or_submitted"),
17638 }
17639
17640
17641def _summarize_turbulence(turbulence_cfg: dict) -> dict:
17642 """!
17643 @brief Build compact turbulence and wall-model selections.
17644 @param[in] turbulence_cfg Case turbulence configuration mapping.
17645 @return Curated turbulence and wall-model mapping.
17646 """
17647 result = {}
17648 for key in ("les", "rans", "wall_function"):
17649 value = turbulence_cfg.get(key)
17650 if isinstance(value, dict):
17651 result[key] = {
17652 "enabled": value.get("enabled", True),
17653 "model": value.get("model"),
17654 **{k: v for k, v in value.items() if k not in {"enabled", "model"}},
17655 }
17656 elif value is not None:
17657 result[key] = value
17658 return result
17659
17660
17661def _build_case_overview(context: dict) -> dict:
17662 """!
17663 @brief Build a curated case.yml summary with useful derived quantities.
17664 @param[in] context Summary context returned by `_build_summary_context()`.
17665 @return Curated case configuration mapping.
17666 """
17667 cfg = _require_summary_config(context, "case")
17668 props = cfg.get("properties", {})
17669 scaling = props.get("scaling", {})
17670 fluid = props.get("fluid", {})
17671 run = cfg.get("run_control", {})
17672 grid = cfg.get("grid", {})
17673 models = cfg.get("models", {})
17674 domain = models.get("domain", {})
17675 physics = models.get("physics", {})
17676 particles = physics.get("particles", {})
17677 start = int(run.get("start_step", 0))
17678 total = int(run.get("total_steps", 0))
17679 dt = float(run.get("dt_physical", 0.0))
17680 length_ref = float(scaling.get("length_ref"))
17681 velocity_ref = float(scaling.get("velocity_ref"))
17682 density = float(fluid.get("density"))
17683 viscosity = float(fluid.get("viscosity"))
17685 first_block_faces = {row["face"]: row for row in prepared_bcs[0]}
17686 periodic_axes = {
17687 "i": first_block_faces["-Xi"]["type"] == "PERIODIC",
17688 "j": first_block_faces["-Eta"]["type"] == "PERIODIC",
17689 "k": first_block_faces["-Zeta"]["type"] == "PERIODIC",
17690 }
17691 bc_blocks = []
17692 for block_idx, block in enumerate(prepared_bcs):
17693 bc_blocks.append(
17694 {
17695 "block": block_idx,
17696 "faces": [
17697 {"face": row["face"], "type": row["type"], "handler": row["handler"]}
17698 for row in block
17699 ],
17700 }
17701 )
17702 return {
17703 "run_control": {
17704 "start_step": start,
17705 "total_steps": total,
17706 "end_step": start + total,
17707 "dt_physical": dt,
17708 "duration_physical": total * dt,
17709 "dt_nondimensional": dt * velocity_ref / length_ref,
17710 },
17711 "properties": {
17712 "length_ref": length_ref,
17713 "velocity_ref": velocity_ref,
17714 "density": density,
17715 "viscosity": viscosity,
17716 "reynolds_number": density * velocity_ref * length_ref / viscosity if viscosity else None,
17717 "initial_conditions": props.get("initial_conditions", {}),
17718 },
17719 "grid": {
17720 "mode": grid.get("mode"),
17721 "processor_layout": resolve_grid_da_processor_layout(grid),
17722 "programmatic_settings": grid.get("programmatic_settings") if grid.get("mode") == "programmatic_c" else None,
17723 "source_file": grid.get("source_file"),
17724 },
17725 "domain": {
17726 "blocks": domain.get("blocks", 1),
17727 "dimensionality": physics.get("dimensionality", "3D"),
17728 "periodic": periodic_axes,
17729 },
17730 "physics": {
17731 "fsi": physics.get("fsi", {}),
17732 "particles": particles,
17733 "turbulence": _summarize_turbulence(physics.get("turbulence", {}) or {}),
17734 },
17735 "boundary_conditions": bc_blocks,
17736 }
17737
17738
17739def _build_solver_overview(context: dict) -> dict:
17740 """!
17741 @brief Build a curated solver.yml summary with normalized selections.
17742 @param[in] context Summary context returned by `_build_summary_context()`.
17743 @return Curated solver configuration mapping.
17744 """
17745 cfg = _require_summary_config(context, "solver")
17746 strategy = cfg.get("strategy", {}) or {}
17747 selected = normalize_momentum_solver_type(strategy.get("momentum_solver", "Dual Time Picard Jameson RK"))
17748 momentum_cfg = cfg.get("momentum_solver", {}) or {}
17749 dualtime = momentum_cfg.get("dual_time_picard_jameson_rk", momentum_cfg.get("dual_time_picard_rk4", {})) or {}
17750 newton_krylov = momentum_cfg.get("newton_krylov", {}) or {}
17751 poisson = cfg.get("poisson_solver", cfg.get("pressure_solver", {})) or {}
17752 operation_mode = cfg.get("operation_mode", {}) or {}
17753 operation_mode = {
17754 **operation_mode,
17755 "eulerian_field_source": normalize_eulerian_field_source(operation_mode.get("eulerian_field_source", "solve")),
17756 }
17757 if operation_mode.get("analytical_type") is not None:
17758 operation_mode["analytical_type"] = normalize_analytical_type(operation_mode["analytical_type"])
17759 passthrough = cfg.get("petsc_passthrough_options", {}) or {}
17760 return {
17761 "operation_mode": operation_mode,
17762 "momentum": {
17763 "type": selected,
17764 "central_diff": bool(strategy.get("central_diff", False)),
17765 "tolerances": cfg.get("tolerances", {}),
17766 "controls": newton_krylov if selected == "newton_krylov" else dualtime,
17767 },
17768 "poisson": poisson,
17769 "interpolation": cfg.get("interpolation", {"method": "Trilinear"}),
17770 "scalar_transport": cfg.get("scalar_transport", {}),
17771 "verification": cfg.get("verification", {}),
17772 "petsc_passthrough": {"count": len(passthrough), "options": sorted(passthrough.keys())},
17773 }
17774
17775
17776def _build_monitor_overview(context: dict) -> dict:
17777 """!
17778 @brief Build a curated monitor.yml summary with resolved defaults.
17779 @param[in] context Summary context returned by `_build_summary_context()`.
17780 @return Curated monitor configuration mapping.
17781 """
17782 cfg = _require_summary_config(context, "monitor")
17783 logging_cfg = cfg.get("logging", {}) or {}
17784 io_cfg = cfg.get("io", {}) or {}
17785 diagnostics = resolve_diagnostics_config(cfg, context["run_dir"], "Solver")
17786 monitoring_flags = resolve_solver_monitoring_flags(cfg)
17787 solution_monitoring = normalize_solution_monitoring_config(cfg)
17788 enabled_petsc = sorted(key for key, value in diagnostics["petsc"].items() if value not in (False, None))
17789 return {
17790 "logging": {
17791 "verbosity": logging_cfg.get("verbosity", "WARNING"),
17792 "enabled_functions": logging_cfg.get("enabled_functions", []),
17793 },
17794 "profiling": resolve_profiling_config(cfg),
17795 "diagnostics": {
17796 "enabled_petsc": enabled_petsc,
17797 "petsc": diagnostics["petsc"],
17798 "runtime_memory_log": diagnostics["runtime_memory_log"],
17799 },
17800 "io": {
17801 "data_output_frequency": io_cfg.get("data_output_frequency"),
17802 "particle_console_output_frequency": resolve_particle_console_output_frequency(io_cfg),
17803 "particle_log_interval": io_cfg.get("particle_log_interval"),
17804 "directories": {
17805 "output": CANONICAL_RUN_PATHS["output"],
17806 "restart": CANONICAL_RUN_PATHS["restart"],
17807 "logs": CANONICAL_RUN_PATHS["logs"],
17808 "analysis": CANONICAL_RUN_PATHS["analysis"],
17809 },
17810 },
17811 "solver_monitoring": {
17812 "enabled_flags": sorted(flag for flag, value in monitoring_flags.items() if value not in (False, None)),
17813 "flags": monitoring_flags,
17814 },
17815 "solution_monitoring": solution_monitoring,
17816 }
17817
17818
17819def _parse_continuity_metrics_log(filepath: str) -> "tuple[dict, list[int]]":
17820 """!
17821 @brief Parse Continuity_Metrics.log into latest rows by step plus observed order.
17822 @param[in] filepath Argument passed to `_parse_continuity_metrics_log()`.
17823 @return Value returned by `_parse_continuity_metrics_log()`.
17824 """
17825 rows_by_step = {}
17826 step_order = []
17827 active_step = None
17828 if not os.path.isfile(filepath):
17829 return rows_by_step, step_order
17830
17831 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
17832 for raw_line in f:
17833 line = raw_line.strip()
17834 if not line or line.startswith("-") or line.startswith("Timestep"):
17835 continue
17836 parts = [part.strip() for part in raw_line.split("|")]
17837 if len(parts) < 8:
17838 continue
17839 step = _parse_int_loose(parts[0])
17840 block = _parse_int_loose(parts[1])
17841 max_div = _parse_float_loose(parts[2])
17842 rhs_sum = _parse_float_loose(parts[4])
17843 flux_in = _parse_float_loose(parts[5])
17844 flux_out = _parse_float_loose(parts[6])
17845 net_flux = _parse_float_loose(parts[7])
17846 if step is None or block is None:
17847 continue
17848 if step != active_step:
17849 active_step = step
17850 step_order.append(step)
17851 rows_by_step[step] = {}
17852 rows_by_step.setdefault(step, {})[block] = {
17853 "block": block,
17854 "max_divergence": max_div,
17855 "max_divergence_location": parts[3],
17856 "rhs_sum": rhs_sum,
17857 "flux_in": flux_in,
17858 "flux_out": flux_out,
17859 "net_flux": net_flux,
17860 }
17861 return {step: list(block_rows.values()) for step, block_rows in rows_by_step.items()}, step_order
17862
17863
17864def _parse_particle_metrics_log(filepath: str) -> "tuple[dict, list[int]]":
17865 """!
17866 @brief Parse Particle_Metrics.log into latest rows by step plus observed order.
17867 @param[in] filepath Argument passed to `_parse_particle_metrics_log()`.
17868 @return Value returned by `_parse_particle_metrics_log()`.
17869 """
17870 rows_by_step = {}
17871 step_order = []
17872 if not os.path.isfile(filepath):
17873 return rows_by_step, step_order
17874
17875 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
17876 for raw_line in f:
17877 line = raw_line.strip()
17878 if not line or line.startswith("-") or line.startswith("Stage"):
17879 continue
17880 parts = [part.strip() for part in raw_line.split("|")]
17881 if len(parts) < 8:
17882 continue
17883 step = _parse_int_loose(parts[1])
17884 if step is None:
17885 continue
17886 row = {
17887 "stage": parts[0],
17888 "total_particles": _parse_int_loose(parts[2]),
17889 "lost_particles": _parse_int_loose(parts[3]),
17890 "lost_particles_cumulative": None,
17891 "migrated_particles": None,
17892 "occupied_cells": None,
17893 "load_imbalance": None,
17894 "migration_passes": None,
17895 }
17896 if len(parts) >= 9:
17897 row.update(
17898 {
17899 "lost_particles_cumulative": _parse_int_loose(parts[4]),
17900 "migrated_particles": _parse_int_loose(parts[5]),
17901 "occupied_cells": _parse_int_loose(parts[6]),
17902 "load_imbalance": _parse_float_loose(parts[7]),
17903 "migration_passes": _parse_int_loose(parts[8]),
17904 }
17905 )
17906 else:
17907 row.update(
17908 {
17909 "migrated_particles": _parse_int_loose(parts[4]),
17910 "occupied_cells": _parse_int_loose(parts[5]),
17911 "load_imbalance": _parse_float_loose(parts[6]),
17912 "migration_passes": _parse_int_loose(parts[7]),
17913 }
17914 )
17915 rows_by_step[step] = row
17916 step_order.append(step)
17917 return rows_by_step, step_order
17918
17919
17920def _parse_momentum_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
17921 """!
17922 @brief Parse per-block momentum convergence logs.
17923 @param[in] log_dir Argument passed to `_parse_momentum_convergence_logs()`.
17924 @return Value returned by `_parse_momentum_convergence_logs()`.
17925 """
17926 rows_by_step = {}
17927 sources = {}
17928 step_order = []
17929 patterns = [
17930 os.path.join(log_dir, "Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
17931 os.path.join(log_dir, "Momentum_Solver_Convergence_History_Block_*.log"),
17932 ]
17933 # Log format (Phase 3+): dtau [physical time] and cfl_eff [dimensionless Courant number].
17934 # cfl_eff = dtau * lambda_max; this is the value controlled by pseudo_cfl.* YAML keys.
17935 regex = re.compile(
17936 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\s*\|"
17937 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
17938 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
17939 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
17940 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
17941 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
17942 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
17943 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
17944 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
17945 )
17946
17947 # Per (step, block): track accepted/rejected counts and last committed state.
17948 _state = {}
17949
17950 for path in sorted(path for pattern in patterns for path in glob.glob(pattern)):
17951 block_match = re.search(r"Block_(\d+)\.log$", path)
17952 if not block_match:
17953 continue
17954 block = int(block_match.group(1))
17955 sources[block] = path
17956 with open(path, "r", encoding="utf-8", errors="replace") as f:
17957 for raw_line in f:
17958 match = regex.search(raw_line)
17959 if not match:
17960 continue
17961 step = int(match.group("step"))
17962 step_order.append(step)
17963 key = (step, block)
17964 if key not in _state:
17965 _state[key] = {"accepted_count": 0, "rejected_count": 0, "last_accepted": None, "last_rejected": None}
17966 entry = _state[key]
17967 status = match.group("status")
17968 row = {
17969 "block": block,
17970 "pseudo_iterations": int(match.group("pseudo_iter")),
17971 "dtau": _parse_float_loose(match.group("dtau")),
17972 "cfl_eff": _parse_float_loose(match.group("cfl_eff")),
17973 "dtau_after": _parse_float_loose(match.group("dtau_after")),
17974 "cfl_eff_after": _parse_float_loose(match.group("cfl_eff_after")),
17975 "delta_norm": float(match.group("delta")),
17976 "delta_rel": float(match.group("delta_rel")),
17977 "residual_norm": float(match.group("resid")),
17978 "residual_rel": float(match.group("resid_rel")),
17979 "trial_ratio": _parse_float_loose(match.group("trial_ratio")),
17980 "smoothed_ratio": _parse_float_loose(match.group("smoothed_ratio")),
17981 "status": status,
17982 }
17983 if status == "accepted":
17984 entry["accepted_count"] += 1
17985 entry["last_accepted"] = row
17986 else:
17987 entry["rejected_count"] += 1
17988 entry["last_rejected"] = row
17989
17990 for (step, block), entry in _state.items():
17991 display_row = entry["last_accepted"] or entry["last_rejected"]
17992 if display_row:
17993 rows_by_step.setdefault(step, {})[block] = {
17994 **display_row,
17995 "accepted_count": entry["accepted_count"],
17996 "rejected_count": entry["rejected_count"],
17997 }
17998
17999 newton_pattern = os.path.join(log_dir, "Momentum_Solver_Newton_Krylov_Summary_Block_*.log")
18000 newton_regex = re.compile(
18001 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
18002 r"\s*solver:\s*(?P<solver>[^|]+?)\s*\|"
18003 # The runtime includes Jacobian and preconditioner descriptions here.
18004 # Keep these optional so summaries can also read the original compact logs.
18005 r"(?:\s*Jacobian:\s*[^|]+?\s*\|\s*Preconditioner:\s*[^|]+?\s*\|)?"
18006 r"\s*reason:\s*(?P<reason>\S+)\s*\|"
18007 r"\s*reason_code:\s*(?P<reason_code>-?\d+)\s*\|\s*newton:\s*(?P<newton>\d+)\s*\|"
18008 r"\s*evals:\s*(?P<evals>\d+)\s*\|\s*krylov:\s*(?P<krylov>\d+)\s*\|"
18009 r"\s*initial:\s*(?P<initial>[-+0-9.eE]+|unavailable)\s*\|"
18010 r"\s*final:\s*(?P<final>[-+0-9.eE]+)\s*\|\s*state:\s*(?P<state>\w+)"
18011 )
18012 for path in sorted(glob.glob(newton_pattern)):
18013 block_match = re.search(r"Block_(\d+)\.log$", path)
18014 if not block_match:
18015 continue
18016 file_block = int(block_match.group(1))
18017 sources[file_block] = path
18018 with open(path, "r", encoding="utf-8", errors="replace") as f:
18019 for raw_line in f:
18020 match = newton_regex.search(raw_line)
18021 if not match:
18022 continue
18023 step = int(match.group("step"))
18024 block = int(match.group("block"))
18025 initial_text = match.group("initial")
18026 step_order.append(step)
18027 rows_by_step.setdefault(step, {})[block] = {
18028 "block": block,
18029 "solver": match.group("solver").strip(),
18030 "reason": match.group("reason"),
18031 "reason_code": int(match.group("reason_code")),
18032 "newton_iterations": int(match.group("newton")),
18033 "residual_evaluations": int(match.group("evals")),
18034 "krylov_iterations": int(match.group("krylov")),
18035 "initial_norm": None if initial_text == "unavailable" else float(initial_text),
18036 "final_norm": float(match.group("final")),
18037 "state": match.group("state"),
18038 }
18039
18040 return rows_by_step, sources, step_order
18041
18042
18043def _parse_poisson_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
18044 """!
18045 @brief Parse per-block Poisson convergence logs.
18046 @param[in] log_dir Argument passed to `_parse_poisson_convergence_logs()`.
18047 @return Value returned by `_parse_poisson_convergence_logs()`.
18048 """
18049 rows_by_step = {}
18050 sources = {}
18051 step_order = []
18052 pattern = os.path.join(log_dir, "Poisson_Solver_Convergence_History_Block_*.log")
18053 regex = re.compile(
18054 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
18055 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
18056 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
18057 )
18058
18059 for path in sorted(glob.glob(pattern)):
18060 block_match = re.search(r"Block_(\d+)\.log$", path)
18061 if not block_match:
18062 continue
18063 block = int(block_match.group(1))
18064 sources[block] = path
18065 with open(path, "r", encoding="utf-8", errors="replace") as f:
18066 for raw_line in f:
18067 match = regex.search(raw_line)
18068 if not match:
18069 continue
18070 step = int(match.group("step"))
18071 step_order.append(step)
18072 rows_by_step.setdefault(step, {})[block] = {
18073 "block": block,
18074 "iterations": int(match.group("iter")),
18075 "unpreconditioned_norm": float(match.group("unpre")),
18076 "true_norm": float(match.group("true")),
18077 "relative_norm": _parse_float_loose(match.group("rel")),
18078 }
18079 return rows_by_step, sources, step_order
18080
18081
18082def _parse_profiling_timestep_csv(filepath: str) -> "tuple[dict, list[int]]":
18083 """!
18084 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
18085 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
18086 @return Value returned by `_parse_profiling_timestep_csv()`.
18087 """
18088 rows_by_step = {}
18089 step_order = []
18090 active_step = None
18091 if not os.path.isfile(filepath):
18092 return rows_by_step, step_order
18093
18094 with open(filepath, "r", encoding="utf-8", errors="replace", newline="") as f:
18095 reader = csv.DictReader(f)
18096 for row in reader:
18097 step = _parse_int_loose(row.get("step"))
18098 if step is None:
18099 continue
18100 if step != active_step:
18101 active_step = step
18102 step_order.append(step)
18103 rows_by_step[step] = []
18104 rows_by_step.setdefault(step, []).append(
18105 {
18106 "function": row.get("function"),
18107 "calls": _parse_int_loose(row.get("calls")),
18108 "step_time_s": _parse_float_loose(row.get("step_time_s")),
18109 }
18110 )
18111 return rows_by_step, step_order
18112
18113
18114def _parse_runtime_memory_log(filepath: str) -> "tuple[dict, list[int], dict]":
18115 """!
18116 @brief Parse Runtime_Memory.log into latest rows by step and final status.
18117 @param[in] filepath Runtime memory log path.
18118 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
18119 """
18120 rows_by_step = {}
18121 step_order = []
18122 final_row = None
18123 latest_sample_row = None
18124 max_process_change_mb = None
18125 if not os.path.isfile(filepath):
18126 return rows_by_step, step_order, {"available": False}
18127
18128 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
18129 for raw_line in f:
18130 line = raw_line.strip()
18131 if not line or line.startswith("#") or line.startswith("Step"):
18132 continue
18133 parts = line.split()
18134 if len(parts) < 8:
18135 continue
18136 step = _parse_int_loose(parts[0])
18137 if step is None:
18138 continue
18139 row = {
18140 "step": step,
18141 "event": parts[1],
18142 "process_current_mb_max": _parse_float_loose(parts[2]),
18143 "process_peak_mb_max": _parse_float_loose(parts[3]),
18144 "petsc_allocated_mb_max": _parse_float_loose(parts[4]),
18145 "petsc_peak_allocated_mb_max": _parse_float_loose(parts[5]),
18146 "process_change_mb_max": _parse_float_loose(parts[6]),
18147 "reason": parts[7],
18148 }
18149 if row["process_change_mb_max"] is not None:
18150 max_process_change_mb = (
18151 row["process_change_mb_max"]
18152 if max_process_change_mb is None
18153 else max(max_process_change_mb, row["process_change_mb_max"])
18154 )
18155 if row["event"] in {"Step", "Post"}:
18156 rows_by_step[step] = row
18157 step_order.append(step)
18158 latest_sample_row = row
18159 elif row["event"] in {"Shutdown", "Final"}:
18160 final_row = row
18161
18162 meta = {
18163 "available": bool(rows_by_step or final_row),
18164 "source": filepath,
18165 "final_event": final_row.get("event") if final_row else None,
18166 "final_reason": final_row.get("reason") if final_row else None,
18167 "max_process_change_mb": max_process_change_mb,
18168 "latest_sample_row": latest_sample_row,
18169 "final_row": final_row,
18170 }
18171 return rows_by_step, step_order, meta
18172
18173
18174def _parse_solution_convergence_log(filepath: str) -> "tuple[dict, list[int]]":
18175 """!
18176 @brief Parse solution_convergence.log into latest rows by step plus observed order.
18177
18178 The log format uses pipe-delimited aligned columns. The first line of the
18179 file is a banner (starts with '=') containing the mode tag; the second line
18180 is the column header; the third line is a separator (starts with '-').
18181 Subsequent lines are one data row per timestep.
18182
18183 @param[in] filepath Path to solution_convergence.log.
18184 @return Mapping of step number to a dict of column values.
18185 """
18186 rows_by_step = {}
18187 step_order = []
18188 if not os.path.isfile(filepath):
18189 return rows_by_step, step_order
18190
18191 mode = None
18192 col_names = None
18193
18194 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
18195 for raw_line in f:
18196 line = raw_line.strip()
18197 if not line:
18198 continue
18199 if line.startswith("="):
18200 m = re.search(r"\[mode:\s*([\w_]+)", line)
18201 if m:
18202 mode = m.group(1)
18203 continue
18204 if line.startswith("-"):
18205 continue
18206 if col_names is None:
18207 col_names = [p.strip() for p in raw_line.split("|")]
18208 continue
18209 parts = [p.strip() for p in raw_line.split("|")]
18210 if len(parts) < 4 or col_names is None:
18211 continue
18212 step = _parse_int_loose(parts[0]) if col_names[0] == "step" else None
18213 if step is None:
18214 continue
18215 step_order.append(step)
18216 row = {"mode": mode}
18217 for name, val in zip(col_names, parts):
18218 if not name:
18219 continue
18220 float_val = _parse_float_loose(val)
18221 int_val = _parse_int_loose(val)
18222 if float_val is not None and ("." in val or "e" in val.lower()):
18223 row[name] = float_val
18224 elif int_val is not None:
18225 row[name] = int_val
18226 else:
18227 row[name] = val
18228 rows_by_step[step] = row
18229 return rows_by_step, step_order
18230
18231
18232def _find_solver_stream_log_candidates(run_dir: str, log_dir: str) -> "list[str]":
18233 """!
18234 @brief Return plausible solver stream logs for local and Slurm runs.
18235 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
18236 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
18237 @return Value returned by `_find_solver_stream_log_candidates()`.
18238 """
18239 patterns = [
18240 os.path.join(run_dir, "scheduler", "*_solver.log"),
18241 os.path.join(run_dir, "scheduler", "solver_*.out"),
18242 os.path.join(log_dir, "*_solver.log"),
18243 ]
18244 found = []
18245 seen = set()
18246 for pattern in patterns:
18247 for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True):
18248 if path not in seen:
18249 seen.add(path)
18250 found.append(path)
18251 return found
18252
18253
18254def _parse_particle_snapshot_file(filepath: str) -> dict:
18255 """!
18256 @brief Parse sampled particle snapshots from a solver stream log.
18257 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
18258 @return Value returned by `_parse_particle_snapshot_file()`.
18259 """
18260 snapshots = {}
18261 if not os.path.isfile(filepath):
18262 return snapshots
18263
18264 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
18265 lines = f.readlines()
18266
18267 idx = 0
18268 while idx < len(lines):
18269 match = re.search(r"Particle states at step\s+(\d+):", lines[idx])
18270 if not match:
18271 idx += 1
18272 continue
18273 step = int(match.group(1))
18274 idx += 1
18275 rows = []
18276 while idx < len(lines):
18277 stripped = lines[idx].strip()
18278 if re.search(r"Particle states at step\s+\d+:", lines[idx]):
18279 break
18280 if stripped.startswith("|"):
18281 parts = [part.strip() for part in stripped.split("|")[1:-1]]
18282 if len(parts) >= 6 and parts[0] != "Rank":
18283 velocity = _extract_numeric_tuple(parts[4])
18284 rows.append(
18285 {
18286 "rank": _parse_int_loose(parts[0]),
18287 "pid": _parse_int_loose(parts[1]),
18288 "cell": [int(value) for value in _extract_numeric_tuple(parts[2])],
18289 "position": _extract_numeric_tuple(parts[3]),
18290 "velocity": velocity,
18291 "weights": _extract_numeric_tuple(parts[5]),
18292 "sample_speed": math.sqrt(sum(component * component for component in velocity)) if len(velocity) == 3 else None,
18293 }
18294 )
18295 elif rows and (not stripped or stripped.startswith("Progress:")):
18296 break
18297 idx += 1
18298 if rows:
18299 snapshots[step] = rows
18300 else:
18301 snapshots.setdefault(step, [])
18302 return snapshots
18303
18304
18305def _find_previous_snapshot_step(snapshot_steps: "list[int]", step: int) -> "int | None":
18306 """!
18307 @brief Return the nearest earlier snapshot step when available.
18308 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
18309 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
18310 @return Value returned by `_find_previous_snapshot_step()`.
18311 """
18312 earlier_steps = [candidate for candidate in snapshot_steps if candidate < step]
18313 if not earlier_steps:
18314 return None
18315 return max(earlier_steps)
18316
18317
18318def _compute_particle_snapshot_delta(current_rows: "list[dict]", previous_rows: "list[dict]") -> dict:
18319 """!
18320 @brief Compute sampled deltas between two particle snapshot samples.
18321 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
18322 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
18323 @return Value returned by `_compute_particle_snapshot_delta()`.
18324 """
18325 np = require_numpy()
18326 previous_by_pid = {
18327 row.get("pid"): row
18328 for row in previous_rows
18329 if row.get("pid") is not None
18330 }
18331 current_by_pid = {
18332 row.get("pid"): row
18333 for row in current_rows
18334 if row.get("pid") is not None
18335 }
18336 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
18337 if not matched_pids:
18338 return {"available": False}
18339
18340 displacements = []
18341 rank_migrations = 0
18342 cell_changes = 0
18343 speed_changes = []
18344 for pid in matched_pids:
18345 current_row = current_by_pid[pid]
18346 previous_row = previous_by_pid[pid]
18347 current_pos = current_row.get("position") or []
18348 previous_pos = previous_row.get("position") or []
18349 if len(current_pos) == len(previous_pos) and current_pos:
18350 displacements.append(
18351 math.sqrt(
18352 sum(
18353 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
18354 for idx in range(len(current_pos))
18355 )
18356 )
18357 )
18358 current_speed = current_row.get("sample_speed")
18359 previous_speed = previous_row.get("sample_speed")
18360 if current_speed is not None and previous_speed is not None:
18361 speed_changes.append(float(current_speed) - float(previous_speed))
18362 if current_row.get("rank") is not None and previous_row.get("rank") is not None:
18363 if current_row["rank"] != previous_row["rank"]:
18364 rank_migrations += 1
18365 if current_row.get("cell") and previous_row.get("cell"):
18366 if current_row["cell"] != previous_row["cell"]:
18367 cell_changes += 1
18368
18369 payload = {
18370 "available": True,
18371 "matched_pids": len(matched_pids),
18372 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
18373 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
18374 "rank_migrations": rank_migrations,
18375 "cell_changes": cell_changes,
18376 }
18377 if displacements:
18378 payload["mean_displacement"] = float(np.mean(displacements))
18379 payload["max_displacement"] = float(np.max(displacements))
18380 if speed_changes:
18381 payload["mean_speed_change"] = float(np.mean(speed_changes))
18382 payload["max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
18383 return payload
18384
18385
18387 source: str,
18388 step: int,
18389 rows: "list[dict]",
18390 preview_rows: int,
18391 particle_console_output_freq,
18392 particle_log_interval,
18393 previous_step: "int | None" = None,
18394 previous_rows: "list[dict] | None" = None,
18395) -> dict:
18396 """!
18397 @brief Build sampled diagnostics for one particle console snapshot.
18398 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
18399 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
18400 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
18401 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
18402 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
18403 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
18404 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
18405 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
18406 @return Value returned by `_build_particle_snapshot_summary()`.
18407 """
18408 np = require_numpy()
18409 payload = {
18410 "available": True,
18411 "sampled": True,
18412 "source": source,
18413 "step": step,
18414 "sampled_rows": len(rows),
18415 "preview_rows": rows[:preview_rows],
18416 "cadence": {
18417 "particle_console_output_frequency": particle_console_output_freq,
18418 "particle_log_interval": particle_log_interval,
18419 },
18420 }
18421 if not rows:
18422 return payload
18423
18424 rank_counts = {}
18425 duplicate_pid_count = 0
18426 duplicate_cell_count = 0
18427 nan_count = 0
18428 inf_count = 0
18429 zero_weight_count = 0
18430 negative_weight_count = 0
18431 unique_pid_count = 0
18432
18433 seen_pids = set()
18434 cell_counter = {}
18435 position_components = [[], [], []]
18436 weight_components = {}
18437 speeds = []
18438
18439 for row in rows:
18440 pid = row.get("pid")
18441 if pid is not None:
18442 if pid in seen_pids:
18443 duplicate_pid_count += 1
18444 else:
18445 seen_pids.add(pid)
18446 rank = row.get("rank")
18447 if rank is not None:
18448 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
18449
18450 cell = row.get("cell") or []
18451 if cell:
18452 key = tuple(cell)
18453 cell_counter[key] = cell_counter.get(key, 0) + 1
18454
18455 position = row.get("position") or []
18456 for idx, value in enumerate(position[:3]):
18457 if not np.isfinite(value):
18458 if np.isnan(value):
18459 nan_count += 1
18460 else:
18461 inf_count += 1
18462 continue
18463 position_components[idx].append(float(value))
18464
18465 velocity = row.get("velocity") or []
18466 if any(not np.isfinite(value) for value in velocity):
18467 for value in velocity:
18468 if not np.isfinite(value):
18469 if np.isnan(value):
18470 nan_count += 1
18471 else:
18472 inf_count += 1
18473 speed = row.get("sample_speed")
18474 if speed is not None and np.isfinite(speed):
18475 speeds.append(float(speed))
18476
18477 weights = row.get("weights") or []
18478 for idx, value in enumerate(weights):
18479 if not np.isfinite(value):
18480 if np.isnan(value):
18481 nan_count += 1
18482 else:
18483 inf_count += 1
18484 continue
18485 numeric = float(value)
18486 weight_components.setdefault(idx, []).append(numeric)
18487 if abs(numeric) <= 1.0e-15:
18488 zero_weight_count += 1
18489 if numeric < 0.0:
18490 negative_weight_count += 1
18491
18492 unique_pid_count = len(seen_pids)
18493 duplicate_cell_count = sum(1 for count in cell_counter.values() if count > 1)
18494
18495 payload["sampled_distribution"] = {
18496 "unique_cells": len(cell_counter),
18497 "duplicate_cells": duplicate_cell_count,
18498 "rank_counts": rank_counts,
18499 "unique_pids": unique_pid_count,
18500 }
18501 payload["checks"] = {
18502 "duplicate_pid_count": duplicate_pid_count,
18503 "nan_count": nan_count,
18504 "inf_count": inf_count,
18505 "zero_weight_count": zero_weight_count,
18506 "negative_weight_count": negative_weight_count,
18507 }
18508
18509 if speeds:
18510 payload["speed"] = {
18511 "min": float(np.min(speeds)),
18512 "mean": float(np.mean(speeds)),
18513 "max": float(np.max(speeds)),
18514 "std": float(np.std(speeds)),
18515 "stagnant_count": sum(1 for speed in speeds if abs(speed) < 1.0e-6),
18516 }
18517
18518 fastest_rows = sorted(
18519 [row for row in rows if row.get("sample_speed") is not None],
18520 key=lambda row: row["sample_speed"],
18521 reverse=True,
18522 )[:3]
18523 payload["top_speeds"] = [
18524 {
18525 "pid": row.get("pid"),
18526 "rank": row.get("rank"),
18527 "speed": row.get("sample_speed"),
18528 "cell": row.get("cell"),
18529 }
18530 for row in fastest_rows
18531 ]
18532
18533 if any(position_components):
18534 axes = ["x", "y", "z"]
18535 payload["position_bounds"] = {}
18536 centroid = []
18537 for idx, axis in enumerate(axes):
18538 values = position_components[idx]
18539 if values:
18540 payload["position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
18541 centroid.append(float(np.mean(values)))
18542 else:
18543 centroid.append(None)
18544 payload["position_centroid"] = centroid
18545
18546 if weight_components:
18547 payload["weights"] = {}
18548 for idx, values in sorted(weight_components.items()):
18549 payload["weights"][f"component_{idx}"] = {
18550 "min": float(np.min(values)),
18551 "max": float(np.max(values)),
18552 }
18553
18554 delta_summary = {"available": False}
18555 if previous_step is not None and previous_rows:
18556 delta_summary = _compute_particle_snapshot_delta(rows, previous_rows)
18557 if delta_summary.get("available"):
18558 delta_summary["previous_step"] = previous_step
18559 payload["delta_from_previous_snapshot"] = delta_summary
18560 return payload
18561
18562
18564 run_dir: str,
18565 log_dir: str,
18566 step: int,
18567 preview_rows: int,
18568 particle_console_output_freq,
18569 particle_log_interval,
18570) -> dict:
18571 """!
18572 @brief Locate and summarize a particle console snapshot for one step.
18573 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
18574 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
18575 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
18576 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
18577 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
18578 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
18579 @return Value returned by `_find_particle_snapshot_for_step()`.
18580 """
18581 best = None
18582 for path in _find_solver_stream_log_candidates(run_dir, log_dir):
18583 snapshots = _parse_particle_snapshot_file(path)
18584 rows = snapshots.get(step)
18585 if not rows:
18586 continue
18587 if best is None or len(rows) > len(best["rows"]):
18588 best = {"source": path, "rows": rows, "snapshots": snapshots}
18589
18590 if not best:
18591 return {"available": False}
18592
18593 previous_step = _find_previous_snapshot_step(list(best["snapshots"].keys()), step)
18594 previous_rows = best["snapshots"].get(previous_step, []) if previous_step is not None else None
18596 best["source"],
18597 step,
18598 best["rows"],
18599 preview_rows,
18600 particle_console_output_freq=particle_console_output_freq,
18601 particle_log_interval=particle_log_interval,
18602 previous_step=previous_step,
18603 previous_rows=previous_rows,
18604 )
18605
18606
18608 requested_step,
18609 continuity_rows,
18610 particle_rows,
18611 momentum_rows,
18612 poisson_rows,
18613 profiling_rows,
18614 memory_rows=None,
18615 convergence_rows=None,
18616 step_orders=None,
18617 selection_mode: str = "latest",
18618):
18619 """!
18620 @brief Select a step to summarize from available metric artifacts.
18621 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
18622 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
18623 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
18624 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
18625 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
18626 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
18627 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
18628 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
18629 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
18630 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
18631 @return Value returned by `_resolve_summary_step()`.
18632 """
18633 if memory_rows is None:
18634 memory_rows = {}
18635 if convergence_rows is None:
18636 convergence_rows = {}
18637 if step_orders is None:
18638 step_orders = []
18639 available_steps = (
18640 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
18641 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
18642 )
18643 if not available_steps:
18644 return None, []
18645
18646 if requested_step is not None:
18647 return requested_step, sorted(available_steps)
18648
18649 if selection_mode == "max_step":
18650 return max(available_steps), sorted(available_steps)
18651
18652 for order in step_orders:
18653 if order:
18654 return order[-1], sorted(available_steps)
18655 return max(available_steps), sorted(available_steps)
18656
18657
18658def _format_summary_float(value, spec: str = ".6e", missing: str = "n/a") -> str:
18659 """!
18660 @brief Format optional numeric values for summary text output.
18661 @param[in] value Argument passed to `_format_summary_float()`.
18662 @param[in] spec Argument passed to `_format_summary_float()`.
18663 @param[in] missing Argument passed to `_format_summary_float()`.
18664 @return Value returned by `_format_summary_float()`.
18665 """
18666 if value is None:
18667 return missing
18668 return format(value, spec)
18669
18670
18671def _summary_source_mtime(paths) -> float:
18672 """!
18673 @brief Return the newest modification time among one or more summary sources.
18674 @param[in] paths Path string, iterable of paths, or mapping of paths.
18675 @return Newest modification time, or -1.0 when no source exists.
18676 """
18677 if isinstance(paths, dict):
18678 paths = paths.values()
18679 elif isinstance(paths, str):
18680 paths = [paths]
18681 newest = -1.0
18682 for path in paths or []:
18683 if path and os.path.isfile(path):
18684 newest = max(newest, os.path.getmtime(path))
18685 return newest
18686
18687
18688def _order_summary_step_orders(sources: "list[tuple[list[int], object]]") -> "list[list[int]]":
18689 """!
18690 @brief Order observed step sequences by the recency of their source files.
18691 @param[in] sources Pairs of observed steps and filesystem source path(s).
18692 @return Step-order lists sorted so active append sources are considered first.
18693 """
18694 ranked = []
18695 for priority, (order, paths) in enumerate(sources):
18696 if order:
18697 ranked.append((_summary_source_mtime(paths), priority, order))
18698 ranked.sort(key=lambda item: (-item[0], item[1]))
18699 return [order for _, _, order in ranked]
18700
18701
18702def build_run_summary_payload(run_dir: str, step: "int | None" = None, snapshot_rows: int = 5, selection_mode: str = "latest") -> dict:
18703 """!
18704 @brief Build a read-only run-step summary from existing PICurv artifacts.
18705 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
18706 @param[in] step Argument passed to `build_run_summary_payload()`.
18707 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
18708 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
18709 @return Value returned by `build_run_summary_payload()`.
18710 """
18711 context = _build_summary_context(run_dir)
18712 log_dir = context["log_dir"]
18713 continuity_path = os.path.join(log_dir, "Continuity_Metrics.log")
18714 particle_metrics_path = os.path.join(log_dir, "Particle_Metrics.log")
18715
18716 continuity_rows, continuity_order = _parse_continuity_metrics_log(continuity_path)
18717 particle_rows, particle_order = _parse_particle_metrics_log(particle_metrics_path)
18718 momentum_rows, momentum_sources, momentum_order = _parse_momentum_convergence_logs(log_dir)
18719 poisson_rows, poisson_sources, poisson_order = _parse_poisson_convergence_logs(log_dir)
18720
18721 profiling_rows = {}
18722 profiling_order = []
18723 profiling_path = os.path.join(log_dir, context["profiling_cfg"].get("timestep_file", "Profiling_Timestep_Summary.csv"))
18724 if context["profiling_cfg"].get("mode") != "off":
18725 profiling_rows, profiling_order = _parse_profiling_timestep_csv(profiling_path)
18726
18727 diagnostics_cfg = resolve_diagnostics_config(context["monitor_cfg"])
18728 memory_log_file = diagnostics_cfg["runtime_memory_log"].get("file", "Runtime_Memory.log")
18729 memory_path = os.path.join(log_dir, memory_log_file)
18730 memory_rows, memory_order, memory_meta = _parse_runtime_memory_log(memory_path)
18731
18732 convergence_log_path = os.path.join(log_dir, "solution_convergence.log")
18733 convergence_rows, convergence_order = _parse_solution_convergence_log(convergence_log_path)
18734 step_orders = _order_summary_step_orders(
18735 [
18736 (continuity_order, continuity_path),
18737 (particle_order, particle_metrics_path),
18738 (convergence_order, convergence_log_path),
18739 (profiling_order, profiling_path),
18740 (memory_order, memory_path),
18741 (momentum_order, momentum_sources),
18742 (poisson_order, poisson_sources),
18743 ]
18744 )
18745
18746 resolved_step, available_steps = _resolve_summary_step(
18747 step,
18748 continuity_rows,
18749 particle_rows,
18750 momentum_rows,
18751 poisson_rows,
18752 profiling_rows,
18753 convergence_rows,
18754 memory_rows,
18755 step_orders=step_orders,
18756 selection_mode=selection_mode,
18757 )
18758 if resolved_step is None:
18760 ERROR_CODE_CFG_FILE_NOT_FOUND,
18761 key="summary",
18762 file_path=log_dir,
18763 message="No summary-capable run artifacts were found under the run log directory.",
18764 hint="Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
18765 )
18766 sys.exit(1)
18767
18768 if step is not None and step not in set(available_steps):
18770 ERROR_CODE_CFG_INVALID_VALUE,
18771 key="step",
18772 file_path=context["run_dir"],
18773 message=f"Requested step {step} is not present in the available summary artifacts.",
18774 hint=f"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
18775 )
18776 sys.exit(1)
18777
18778 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=lambda row: row["block"])
18779 continuity_summary = {"available": bool(continuity_step_rows), "blocks": continuity_step_rows}
18780 if continuity_step_rows:
18781 divergence_values = [
18782 abs(row["max_divergence"])
18783 for row in continuity_step_rows
18784 if row["max_divergence"] is not None
18785 ]
18786 continuity_summary["max_abs_divergence"] = max(divergence_values) if divergence_values else None
18787 continuity_summary["net_flux"] = continuity_step_rows[0].get("net_flux")
18788 continuity_summary["flux_in"] = continuity_step_rows[0].get("flux_in")
18789 continuity_summary["flux_out"] = continuity_step_rows[0].get("flux_out")
18790
18791 momentum_step_rows = [row for _, row in sorted(momentum_rows.get(resolved_step, {}).items())]
18792 momentum_summary = {"available": bool(momentum_step_rows), "blocks": momentum_step_rows}
18793
18794 poisson_step_rows = [row for _, row in sorted(poisson_rows.get(resolved_step, {}).items())]
18795 poisson_summary = {"available": bool(poisson_step_rows), "blocks": poisson_step_rows}
18796
18797 particle_summary = {"available": resolved_step in particle_rows}
18798 if resolved_step in particle_rows:
18799 particle_summary.update(particle_rows[resolved_step])
18800
18801 profiling_summary = {"available": resolved_step in profiling_rows}
18802 if resolved_step in profiling_rows:
18803 functions = sorted(
18804 profiling_rows[resolved_step],
18805 key=lambda row: (row.get("step_time_s") or 0.0),
18806 reverse=True,
18807 )
18808 profiling_summary["functions"] = functions
18809 profiling_summary["total_logged_step_time_s"] = sum(
18810 row.get("step_time_s") or 0.0 for row in functions
18811 )
18812
18813 memory_summary = {"available": resolved_step in memory_rows}
18814 if resolved_step in memory_rows:
18815 memory_summary.update(memory_rows[resolved_step])
18816 memory_summary["source"] = memory_path
18817 memory_summary["max_process_change_mb"] = memory_meta.get("max_process_change_mb")
18818 memory_summary["final_event"] = memory_meta.get("final_event")
18819 memory_summary["final_reason"] = memory_meta.get("final_reason")
18820 memory_summary["selected_step"] = resolved_step
18821 memory_summary["step_match"] = True
18822 elif memory_meta.get("available"):
18823 latest_sample_row = memory_meta.get("latest_sample_row")
18824 if latest_sample_row:
18825 memory_summary.update(latest_sample_row)
18826 memory_summary.update(memory_meta)
18827 memory_summary["selected_step"] = resolved_step
18828 memory_summary["step_match"] = False
18829
18830 snapshot_summary = {"available": False}
18831 if context["particle_console_output_freq"] and context["particle_console_output_freq"] > 0:
18832 snapshot_summary = _find_particle_snapshot_for_step(
18833 context["run_dir"],
18834 log_dir,
18835 resolved_step,
18836 preview_rows=max(1, snapshot_rows),
18837 particle_console_output_freq=context["particle_console_output_freq"],
18838 particle_log_interval=context["particle_log_interval"],
18839 )
18840
18841 monitor_info = {
18842 "profiling_timestep_mode": context["profiling_cfg"].get("mode"),
18843 "profiling_timestep_file": context["profiling_cfg"].get("timestep_file"),
18844 "particle_console_output_frequency": context["particle_console_output_freq"],
18845 "particle_log_interval": context["particle_log_interval"],
18846 }
18847
18848 return {
18849 "run_id": context["manifest"].get("run_id", os.path.basename(context["run_dir"])),
18850 "run_dir": context["run_dir"],
18851 "step": resolved_step,
18852 "selected_via": "explicit" if step is not None else ("max_step" if selection_mode == "max_step" else "latest_available"),
18853 "available_steps": available_steps,
18854 "launch_mode": context["manifest"].get("launch_mode"),
18855 "created_at": context["manifest"].get("created_at"),
18856 "monitor": monitor_info,
18857 "particles_configured": context["particle_count_cfg"],
18858 "sources": {
18859 "continuity_log": continuity_path if os.path.isfile(continuity_path) else None,
18860 "particle_metrics_log": particle_metrics_path if os.path.isfile(particle_metrics_path) else None,
18861 "momentum_logs": momentum_sources,
18862 "poisson_logs": poisson_sources,
18863 "profiling_timestep_csv": profiling_path if os.path.isfile(profiling_path) else None,
18864 "solution_convergence_log": convergence_log_path if os.path.isfile(convergence_log_path) else None,
18865 "runtime_memory_log": memory_path if os.path.isfile(memory_path) else None,
18866 },
18867 "continuity": continuity_summary,
18868 "momentum": momentum_summary,
18869 "poisson": poisson_summary,
18870 "particles": particle_summary,
18871 "particle_snapshot": snapshot_summary,
18872 "profiling": profiling_summary,
18873 "memory": memory_summary,
18874 "convergence": convergence_rows.get(resolved_step) if convergence_rows else None,
18875 }
18876
18877
18878def render_run_summary(payload: dict, output_format: str = "text"):
18879 """!
18880 @brief Render a run-step summary in human or JSON form.
18881 @param[in] payload Argument passed to `render_run_summary()`.
18882 @param[in] output_format Argument passed to `render_run_summary()`.
18883 """
18884 if output_format == "json":
18885 print(json.dumps(payload, indent=2, sort_keys=True))
18886 return
18887
18888 print("\n" + "=" * 60)
18889 print(" RUN STEP SUMMARY")
18890 print("=" * 60)
18891 print(f" Run ID : {payload.get('run_id')}")
18892 print(f" Run directory : {os.path.relpath(payload.get('run_dir'))}")
18893 print(f" Step : {payload.get('step')} ({payload.get('selected_via')})")
18894 if payload.get("launch_mode"):
18895 print(f" Launch mode : {payload.get('launch_mode')}")
18896 if payload.get("created_at"):
18897 print(f" Created at : {payload.get('created_at')}")
18898
18899 continuity = payload.get("continuity", {})
18900 print("\n Continuity:")
18901 if continuity.get("available"):
18902 if continuity.get("max_abs_divergence") is not None:
18903 print(f" max |div| : {continuity['max_abs_divergence']:.6e}")
18904 if continuity.get("net_flux") is not None:
18905 print(f" net flux : {continuity['net_flux']:.6e}")
18906 for row in continuity.get("blocks", []):
18907 print(
18908 " "
18909 f"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
18910 f"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
18911 )
18912 else:
18913 print(" unavailable")
18914
18915 momentum = payload.get("momentum", {})
18916 print("\n Momentum:")
18917 if momentum.get("available"):
18918 for row in momentum.get("blocks", []):
18919 if row.get("solver") == "Newton Krylov":
18920 print(f" block {row['block']}: solver=Newton Krylov")
18921 print(
18922 f" newton={row.get('newton_iterations')} "
18923 f"krylov={row.get('krylov_iterations')} "
18924 f"evals={row.get('residual_evaluations')}"
18925 )
18926 print(
18927 f" final={_format_summary_float(row.get('final_norm'))} "
18928 f"reason={row.get('reason')} state={row.get('state')}"
18929 )
18930 continue
18931 status = row.get("status") or "unknown"
18932 accepted = row.get("accepted_count")
18933 rejected = row.get("rejected_count")
18934 counts_str = f" accepted={accepted} rejected={rejected}" if accepted is not None else ""
18935 # Phase 3+: logged as dtau (physical time) + cfl_eff (dimensionless Courant number).
18936 dtau_val = row.get("dtau")
18937 cfl_in = row.get("cfl_eff")
18938 cfl_out = row.get("cfl_eff_after")
18939 dtau_out = row.get("dtau_after")
18940 if cfl_in is not None and cfl_out is not None:
18941 cfl_str = f"cfl_eff {cfl_in:.4f}->{cfl_out:.4f} dtau {_format_summary_float(dtau_val)}->{_format_summary_float(dtau_out)}"
18942 elif cfl_in is not None:
18943 cfl_str = f"cfl_eff={_format_summary_float(cfl_in, '.4f')} dtau={_format_summary_float(dtau_val)}"
18944 else:
18945 cfl_str = "cfl_eff=n/a"
18946 ratio = row.get("trial_ratio")
18947 smoothed = row.get("smoothed_ratio")
18948 if ratio is not None and smoothed is not None:
18949 ratio_str = f" ratio={_format_summary_float(ratio)} (ema={_format_summary_float(smoothed)})"
18950 elif ratio is not None:
18951 ratio_str = f" ratio={_format_summary_float(ratio)}"
18952 else:
18953 ratio_str = ""
18954 print(f" block {row['block']} [{status}]:{counts_str} {cfl_str}{ratio_str}")
18955 print(
18956 f" resid={_format_summary_float(row.get('residual_norm'))}"
18957 f" delta={_format_summary_float(row.get('delta_norm'))}"
18958 )
18959 else:
18960 print(" unavailable")
18961
18962 poisson = payload.get("poisson", {})
18963 print("\n Poisson:")
18964 if poisson.get("available"):
18965 for row in poisson.get("blocks", []):
18966 print(
18967 " "
18968 f"block {row['block']}: iter={row['iterations']} "
18969 f"true={_format_summary_float(row.get('true_norm'))} "
18970 f"rel={_format_summary_float(row.get('relative_norm'))}"
18971 )
18972 else:
18973 print(" unavailable")
18974
18975 convergence = payload.get("convergence")
18976 print("\n Solution Convergence:")
18977 if convergence is not None:
18978 mode = convergence.get("mode", "unknown")
18979 ref = convergence.get("ref")
18980 print(f" mode : {mode} (ref={'yes' if ref else 'no'})")
18981 if mode in ("steady_deterministic", "transient"):
18982 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
18983 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
18984 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
18985 elif mode == "periodic_deterministic":
18986 ph = convergence.get("ph")
18987 per = convergence.get("per")
18988 print(f" phase : {ph}/{per}")
18989 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
18990 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
18991 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
18992 elif mode == "statistical_steady":
18993 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} win={_format_summary_float(convergence.get('spd_win'))} win_drift={_format_summary_float(convergence.get('spd_win_abs'))}")
18994 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} win={_format_summary_float(convergence.get('ke_win'))} win_drift={_format_summary_float(convergence.get('ke_win_abs'))}")
18995 else:
18996 print(" unavailable")
18997
18998 particles = payload.get("particles", {})
18999 print("\n Particles:")
19000 if particles.get("available"):
19001 loss_summary = f"lost={particles.get('lost_particles')}"
19002 if particles.get("lost_particles_cumulative") is not None:
19003 loss_summary = (
19004 f"lost(step/total)={particles.get('lost_particles')}/"
19005 f"{particles.get('lost_particles_cumulative')}"
19006 )
19007 print(
19008 " "
19009 f"total={particles.get('total_particles')} {loss_summary} "
19010 f"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
19011 f"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
19012 )
19013 else:
19014 print(" unavailable")
19015
19016 memory = payload.get("memory", {})
19017 print("\n Runtime Memory:")
19018 if memory.get("available"):
19019 if memory.get("source"):
19020 print(f" source : {os.path.relpath(memory.get('source'))}")
19021 if memory.get("step") is not None and not memory.get("step_match", True):
19022 print(f" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
19023 if memory.get("event"):
19024 print(f" event : {memory.get('event')} reason={memory.get('reason', '-')}")
19025 print(f" process max : {_format_summary_float(memory.get('process_current_mb_max'), '.3f')} MB current, {_format_summary_float(memory.get('process_peak_mb_max'), '.3f')} MB peak")
19026 print(f" PETSc max : {_format_summary_float(memory.get('petsc_allocated_mb_max'), '.3f')} MB allocated, {_format_summary_float(memory.get('petsc_peak_allocated_mb_max'), '.3f')} MB peak")
19027 print(f" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
19028 if memory.get("final_reason"):
19029 print(f" final reason : {memory.get('final_reason')}")
19030 else:
19031 print(" unavailable")
19032
19033 snapshot = payload.get("particle_snapshot", {})
19034 if snapshot.get("available"):
19035 print("\n Particle Snapshot (sampled):")
19036 print(f" source : {os.path.relpath(snapshot.get('source'))}")
19037 cadence = snapshot.get("cadence", {})
19038 print(
19039 " "
19040 f"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
19041 f"row interval {cadence.get('particle_log_interval', 'n/a')}"
19042 )
19043 print(f" sampled rows : {snapshot.get('sampled_rows')}")
19044 speed = snapshot.get("speed", {})
19045 if speed:
19046 print(
19047 " "
19048 f"sampled speeds: min={_format_summary_float(speed.get('min'))} "
19049 f"mean={_format_summary_float(speed.get('mean'))} "
19050 f"max={_format_summary_float(speed.get('max'))} "
19051 f"std={_format_summary_float(speed.get('std'))} "
19052 f"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
19053 )
19054 bounds = snapshot.get("position_bounds", {})
19055 centroid = snapshot.get("position_centroid")
19056 if bounds:
19057 bound_parts = []
19058 for axis in ("x", "y", "z"):
19059 if axis in bounds:
19060 bound_parts.append(
19061 f"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
19062 )
19063 print(f" sampled bounds: {' '.join(bound_parts)}")
19064 if centroid:
19065 print(
19066 " "
19067 f"sampled center: ({_format_summary_float(centroid[0])}, "
19068 f"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
19069 )
19070 distribution = snapshot.get("sampled_distribution", {})
19071 if distribution:
19072 print(
19073 " "
19074 f"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
19075 f"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
19076 f"unique_pids={distribution.get('unique_pids', 'n/a')} "
19077 f"ranks={distribution.get('rank_counts', {})}"
19078 )
19079 weights = snapshot.get("weights", {})
19080 if weights:
19081 weight_parts = []
19082 for component, summary in sorted(weights.items()):
19083 weight_parts.append(
19084 f"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
19085 )
19086 print(f" sampled weights: {' '.join(weight_parts)}")
19087 checks = snapshot.get("checks", {})
19088 if checks:
19089 print(
19090 " "
19091 f"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
19092 f"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
19093 f"zero_weight={checks.get('zero_weight_count', 0)} "
19094 f"negative_weight={checks.get('negative_weight_count', 0)}"
19095 )
19096 top_speeds = snapshot.get("top_speeds", [])
19097 if top_speeds:
19098 summary = ", ".join(
19099 f"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
19100 for row in top_speeds
19101 )
19102 print(f" top speeds : {summary}")
19103 delta_summary = snapshot.get("delta_from_previous_snapshot", {})
19104 if delta_summary.get("available"):
19105 print(
19106 " "
19107 f"vs prev snap : step={delta_summary.get('previous_step')} "
19108 f"matched_pids={delta_summary.get('matched_pids')} "
19109 f"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
19110 f"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
19111 f"rank_moves={delta_summary.get('rank_migrations')} "
19112 f"cell_changes={delta_summary.get('cell_changes')} "
19113 f"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
19114 )
19115 print(" preview rows :")
19116 for row in snapshot.get("preview_rows", []):
19117 print(
19118 " "
19119 f"pid={row.get('pid')} rank={row.get('rank')} "
19120 f"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
19121 )
19122
19123 profiling = payload.get("profiling", {})
19124 print("\n Profiling:")
19125 if profiling.get("available"):
19126 print(f" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
19127 for row in profiling.get("functions", [])[:5]:
19128 print(
19129 " "
19130 f"{row.get('function')}: calls={row.get('calls')} "
19131 f"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
19132 )
19133 else:
19134 print(" unavailable")
19135 print("=" * 60)
19136
19137
19138_CONFIG_SUMMARY_WIDTH = 78
19139
19140
19141def _summary_display_value(value) -> str:
19142 """!
19143 @brief Format one configuration-summary value for compact text output.
19144 @param[in] value Value to format.
19145 @return Compact human-readable value.
19146 """
19147 if value is None:
19148 return "-"
19149 if isinstance(value, bool):
19150 return "enabled" if value else "disabled"
19151 if isinstance(value, float):
19152 return f"{value:.6g}"
19153 if isinstance(value, (list, tuple)):
19154 return ", ".join(_summary_display_value(item) for item in value) if value else "none"
19155 if isinstance(value, dict):
19156 if not value:
19157 return "none"
19158 return ", ".join(f"{key}={_summary_display_value(item)}" for key, item in value.items())
19159 return str(value)
19160
19161
19162def _print_config_header(title: str, subtitle: "str | None" = None):
19163 """!
19164 @brief Print a strong dashboard-style configuration summary header.
19165 @param[in] title Section title.
19166 @param[in] subtitle Optional one-line section subtitle.
19167 """
19168 print("\n" + "=" * _CONFIG_SUMMARY_WIDTH)
19169 print(f"{title:^78}")
19170 if subtitle:
19171 print(f"{subtitle:^78}")
19172 print("=" * _CONFIG_SUMMARY_WIDTH)
19173
19174
19175def _print_config_group(title: str, rows: list):
19176 """!
19177 @brief Print an aligned configuration-summary field group.
19178 @param[in] title Group title.
19179 @param[in] rows Sequence of `(label, value)` pairs.
19180 """
19181 visible_rows = [(label, value) for label, value in rows if value is not None]
19182 if not visible_rows:
19183 return
19184 print(f"\n {title}")
19185 print(f" {'-' * (len(title) + 1)}")
19186 for label, value in visible_rows:
19187 print(f" {label:<32} {_summary_display_value(value)}")
19188
19189
19190def _flatten_summary_mapping(mapping: dict, prefix: str = "") -> list:
19191 """!
19192 @brief Flatten nested summary mappings into readable dotted field rows.
19193 @param[in] mapping Mapping to flatten.
19194 @param[in] prefix Optional parent-field prefix.
19195 @return Sequence of `(field, value)` pairs.
19196 """
19197 rows = []
19198 for key, value in mapping.items():
19199 label = f"{prefix}.{key}" if prefix else str(key)
19200 if isinstance(value, dict) and value:
19201 rows.extend(_flatten_summary_mapping(value, label))
19202 else:
19203 rows.append((label, value))
19204 return rows
19205
19206
19207def _render_run_overview_text(summary: dict):
19208 """!
19209 @brief Render run metadata as a compact dashboard.
19210 @param[in] summary Curated run overview mapping.
19211 """
19212 _print_config_header("RUN OVERVIEW", summary.get("run_id"))
19214 "Identity",
19215 [
19216 ("Run directory", os.path.relpath(summary.get("run_dir")) if summary.get("run_dir") else None),
19217 ("Created", summary.get("created_at")),
19218 ("Launch mode", summary.get("launch_mode")),
19219 ("PICurv release", summary.get("release_version")),
19220 ("Build", summary.get("build_id")),
19221 ("Git commit", summary.get("git_commit")),
19222 ],
19223 )
19225 "Execution",
19226 [
19227 ("Solver MPI processes", summary.get("solver_num_procs")),
19228 ("Post MPI processes", summary.get("post_num_procs")),
19229 ("Stages requested", summary.get("stages_requested")),
19230 ("Stages ready/completed", summary.get("stages_completed_or_submitted")),
19231 ],
19232 )
19233
19234
19235def _render_case_summary_text(summary: dict):
19236 """!
19237 @brief Render the case summary as a glanceable simulation dashboard.
19238 @param[in] summary Curated case configuration mapping.
19239 """
19240 run = summary.get("run_control", {})
19241 props = summary.get("properties", {})
19242 grid = summary.get("grid", {})
19243 domain = summary.get("domain", {})
19244 physics = summary.get("physics", {})
19245 subtitle = (
19246 f"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
19247 f"Re={_summary_display_value(props.get('reynolds_number'))}"
19248 )
19249 _print_config_header("CASE SUMMARY", subtitle)
19251 "Simulation",
19252 [
19253 ("Step range", f"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
19254 ("Physical timestep", run.get("dt_physical")),
19255 ("Nondimensional timestep", run.get("dt_nondimensional")),
19256 ("Physical duration", run.get("duration_physical")),
19257 ("Initial conditions", props.get("initial_conditions")),
19258 ],
19259 )
19261 "Fluid And Scaling",
19262 [
19263 ("Reynolds number", props.get("reynolds_number")),
19264 ("Reference length", props.get("length_ref")),
19265 ("Reference velocity", props.get("velocity_ref")),
19266 ("Density", props.get("density")),
19267 ("Viscosity", props.get("viscosity")),
19268 ],
19269 )
19271 "Domain And Grid",
19272 [
19273 ("Grid mode", grid.get("mode")),
19274 ("Blocks", domain.get("blocks")),
19275 ("Dimensionality", domain.get("dimensionality")),
19276 ("Periodic axes", domain.get("periodic")),
19277 ("MPI grid layout", grid.get("processor_layout")),
19278 ("Grid source", grid.get("source_file")),
19279 ],
19280 )
19281 if grid.get("programmatic_settings"):
19282 _print_config_group("Programmatic Grid", _flatten_summary_mapping(grid["programmatic_settings"]))
19284 "Physics",
19285 [
19286 ("Particles", physics.get("particles")),
19287 ("FSI", physics.get("fsi")),
19288 ("Turbulence", physics.get("turbulence")),
19289 ("Statistics", physics.get("statistics")),
19290 ],
19291 )
19292 boundary_blocks = summary.get("boundary_conditions", [])
19293 if boundary_blocks:
19294 print("\n Boundary Conditions")
19295 print(" --------------------")
19296 print(f" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
19297 print(f" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
19298 for block in boundary_blocks:
19299 for face in block.get("faces", []):
19300 print(
19301 f" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
19302 f"{face.get('type', '-'):<12} {face.get('handler', '-')}"
19303 )
19304
19305
19307 """!
19308 @brief Render the solver summary as a glanceable numerical-method dashboard.
19309 @param[in] summary Curated solver configuration mapping.
19310 """
19311 momentum = summary.get("momentum", {})
19312 poisson = summary.get("poisson", {})
19313 operation = summary.get("operation_mode", {})
19314 subtitle = (
19315 f"Field: {operation.get('eulerian_field_source', '-')} | "
19316 f"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
19317 )
19318 _print_config_header("SOLVER SUMMARY", subtitle)
19319 _print_config_group("Operation", _flatten_summary_mapping(operation))
19321 "Primary Methods",
19322 [
19323 ("Momentum solver", momentum.get("type")),
19324 ("Central differencing", momentum.get("central_diff")),
19325 ("Poisson method", poisson.get("method")),
19326 ("Interpolation", summary.get("interpolation")),
19327 ("Convergence mode", summary.get("solution_convergence", {}).get("mode")),
19328 ],
19329 )
19330 _print_config_group("Momentum Tolerances", _flatten_summary_mapping(momentum.get("tolerances", {})))
19331 control_heading = (
19332 "Newton--Krylov Controls" if momentum.get("type") == "newton_krylov"
19333 else "Dual-Time Pseudo-Time Controls" if momentum.get("type") == "DUALTIME_PICARD_JAMESON_RK"
19334 else "Momentum Controls"
19335 )
19336 _print_config_group(control_heading, _flatten_summary_mapping(momentum.get("controls", {})))
19337 _print_config_group("Poisson Configuration", _flatten_summary_mapping(poisson))
19338 _print_config_group("Solution Convergence", _flatten_summary_mapping(summary.get("solution_convergence", {})))
19339 _print_config_group("Scalar Transport", _flatten_summary_mapping(summary.get("scalar_transport", {})))
19340 _print_config_group("Verification Sources", _flatten_summary_mapping(summary.get("verification", {})))
19341 passthrough = summary.get("petsc_passthrough", {})
19343 "Advanced PETSc Options",
19344 [("Option count", passthrough.get("count")), ("Option names", passthrough.get("options"))],
19345 )
19346
19347
19349 """!
19350 @brief Render the monitor summary as a glanceable observability dashboard.
19351 @param[in] summary Curated monitor configuration mapping.
19352 """
19353 logging_cfg = summary.get("logging", {})
19354 profiling = summary.get("profiling", {})
19355 diagnostics = summary.get("diagnostics", {})
19356 io_cfg = summary.get("io", {})
19357 memory_log = diagnostics.get("runtime_memory_log", {})
19358 subtitle = (
19359 f"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
19360 f"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
19361 )
19362 _print_config_header("MONITOR SUMMARY", subtitle)
19364 "Logging",
19365 [
19366 ("Verbosity", logging_cfg.get("verbosity")),
19367 ("Enabled functions", logging_cfg.get("enabled_functions")),
19368 ],
19369 )
19370 _print_config_group("Profiling", _flatten_summary_mapping(profiling))
19372 "Output Cadence",
19373 [
19374 ("Field output", io_cfg.get("data_output_frequency")),
19375 ("Particle snapshots", io_cfg.get("particle_console_output_frequency")),
19376 ("Particle row interval", io_cfg.get("particle_log_interval")),
19377 ],
19378 )
19379 _print_config_group("Output Directories", _flatten_summary_mapping(io_cfg.get("directories", {})))
19381 "Diagnostics",
19382 [
19383 ("Enabled PETSc diagnostics", diagnostics.get("enabled_petsc")),
19384 ("Runtime memory log", memory_log.get("enabled")),
19385 ("Runtime memory file", memory_log.get("file")),
19386 ],
19387 )
19388 _print_config_group("PETSc Diagnostic Settings", _flatten_summary_mapping(diagnostics.get("petsc", {})))
19389 solver_monitoring = summary.get("solver_monitoring", {})
19391 "Solver Monitoring",
19392 [
19393 ("Enabled flags", solver_monitoring.get("enabled_flags")),
19394 ("All flags", solver_monitoring.get("flags")),
19395 ],
19396 )
19397
19398
19399def render_selected_summary(payload: dict, output_format: str = "text"):
19400 """!
19401 @brief Render selected timestep-independent config views and optional health.
19402 @param[in] payload Combined selected summary payload.
19403 @param[in] output_format Output format.
19404 """
19405 if output_format == "json":
19406 json_payload = {key: value for key, value in payload.items() if key != "_health_requested"}
19407 print(json.dumps(json_payload, indent=2, sort_keys=True))
19408 return
19409
19410 if payload.get("run_overview") is not None:
19411 _render_run_overview_text(payload["run_overview"])
19412 renderers = {
19413 "case": _render_case_summary_text,
19414 "solver": _render_solver_summary_text,
19415 "monitor": _render_monitor_summary_text,
19416 }
19417 for key in ("case", "solver", "monitor"):
19418 if key in payload.get("configuration", {}):
19419 renderers[key](payload["configuration"][key])
19420 storage = payload.get("storage")
19421 if isinstance(storage, dict):
19422 print("\nSTORAGE")
19423 print("=" * 78)
19424 print(f" State : {storage.get('state', 'LOCAL')}")
19425 if storage.get("archive_id"):
19426 print(f" Archive ID : {storage['archive_id']}")
19427 if storage.get("label"):
19428 print(f" Label : {storage['label']}")
19429 if payload.get("_health_requested"):
19430 health_payload = {
19431 key: value for key, value in payload.items()
19432 if key not in {"run_overview", "configuration", "storage", "_health_requested"}
19433 }
19434 render_run_summary(health_payload, output_format="text")
19435
19436
19437_SUMMARY_PLOT_LOG_SCALE_FIELDS = {
19438 "max_divergence", "delta_norm", "delta_rel", "residual_norm", "residual_rel",
19439 "unpreconditioned_norm", "true_norm", "relative_norm",
19440 "u_abs_l2", "u_rel_l2", "p_abs_l2", "p_rel_l2",
19441 "spd_abs", "spd_rel", "ke_abs", "ke_rel",
19442 "spd_win_abs", "spd_win_rel", "spd_rms_abs", "spd_rms_rel",
19443 "ke_win_abs", "ke_win_rel", "ke_rms_abs", "ke_rms_rel",
19444 "parseval_residual", "zero_mode_energy",
19445}
19446
19447
19448_SUMMARY_PLOT_FIELD_LABELS = {
19449 "max_divergence": "Maximum |divergence|",
19450 "rhs_sum": "Continuity right-hand-side sum",
19451 "flux_in": "Inflow flux",
19452 "flux_out": "Outflow flux",
19453 "net_flux": "Net boundary flux",
19454 "total_particles": "Particle count",
19455 "lost_particles": "Particles lost per step",
19456 "lost_particles_cumulative": "Cumulative particles lost",
19457 "migrated_particles": "Migrated particles",
19458 "occupied_cells": "Occupied cells",
19459 "load_imbalance": "Particle load imbalance",
19460 "migration_passes": "Particle migration passes",
19461 "pseudo_iterations": "Pseudo-iterations to convergence",
19462 "newton_iterations": "Newton iterations to convergence",
19463 "iterations": "Linear iterations to convergence",
19464 "dtau": "Pseudo-time step, Δτ",
19465 "dtau_after": "Accepted pseudo-time step, Δτ",
19466 "cfl_eff": "Effective pseudo-CFL",
19467 "cfl_eff_after": "Accepted effective pseudo-CFL",
19468 "delta_norm": "Momentum update norm, ‖ΔU‖",
19469 "delta_rel": "Relative momentum update, ‖ΔU‖/‖ΔU₀‖",
19470 "residual_norm": "Residual norm",
19471 "residual_rel": "Relative residual norm",
19472 "trial_ratio": "Pseudo-CFL trial ratio",
19473 "smoothed_ratio": "Smoothed pseudo-CFL ratio",
19474 "unpreconditioned_norm": "Unpreconditioned residual norm",
19475 "true_norm": "True residual norm",
19476 "relative_norm": "Relative residual norm",
19477 "calls": "Calls per physical step",
19478 "step_time_s": "Wall time per physical step (s)",
19479 "process_current_mb_max": "Maximum resident memory (MiB)",
19480 "process_peak_mb_max": "Peak resident memory (MiB)",
19481 "petsc_allocated_mb_max": "PETSc allocated memory (MiB)",
19482 "petsc_peak_allocated_mb_max": "Peak PETSc allocated memory (MiB)",
19483 "process_change_mb_max": "Resident-memory change (MiB)",
19484 "u_abs_l2": "Velocity L² error",
19485 "u_rel_l2": "Relative velocity L² error",
19486 "p_abs_l2": "Pressure L² error",
19487 "p_rel_l2": "Relative pressure L² error",
19488 "mean_speed": "Volume-mean speed",
19489 "spd_ref": "Reference mean speed",
19490 "spd_abs": "Absolute mean-speed drift",
19491 "spd_rel": "Relative mean-speed drift",
19492 "mean_ke": "Mean kinetic energy",
19493 "ke_ref": "Reference mean kinetic energy",
19494 "ke_abs": "Absolute kinetic-energy drift",
19495 "ke_rel": "Relative kinetic-energy drift",
19496 "spd_win": "Window-mean speed",
19497 "spd_win_prev": "Previous window-mean speed",
19498 "spd_win_abs": "Absolute window-mean speed drift",
19499 "spd_win_rel": "Relative window-mean speed drift",
19500 "spd_rms_win": "Window RMS mean speed",
19501 "spd_rms_abs": "Absolute RMS speed drift",
19502 "spd_rms_rel": "Relative RMS speed drift",
19503 "ke_win": "Window-mean kinetic energy",
19504 "ke_win_prev": "Previous window-mean kinetic energy",
19505 "ke_win_abs": "Absolute window-mean kinetic-energy drift",
19506 "ke_win_rel": "Relative window-mean kinetic-energy drift",
19507 "ke_rms_win": "Window RMS kinetic energy",
19508 "ke_rms_abs": "Absolute RMS kinetic-energy drift",
19509 "ke_rms_rel": "Relative RMS kinetic-energy drift",
19510 "resolved_kinetic_energy": "Resolved kinetic energy",
19511 "spectrum_total_energy": "Integrated spectral energy",
19512 "parseval_residual": "Parseval residual",
19513 "spectrum_peak_k": "Peak wavenumber, kₚₑₐₖ",
19514 "zero_mode_energy": "Zero-mode energy",
19515 "integral_length_scale": "Integral length scale",
19516 "taylor_microscale": "Taylor microscale",
19517 "dissipation_over_viscosity": "Dissipation / kinematic viscosity",
19518}
19519
19520
19521_SUMMARY_PLOT_SOURCE_TITLES = {
19522 "continuity": "Continuity",
19523 "particles": "Particle transport",
19524 "momentum": "Momentum solver",
19525 "poisson": "Pressure Poisson solver",
19526 "profiling": "Runtime profile",
19527 "memory": "Runtime memory",
19528 "convergence": "Solution convergence",
19529 "spectra": "Turbulence spectrum",
19530}
19531
19532
19533_SUMMARY_ITERATION_HISTORY_FIELDS = {
19534 "dtau", "dtau_after", "cfl_eff", "cfl_eff_after", "delta_norm", "delta_rel",
19535 "residual_norm", "residual_rel", "trial_ratio", "smoothed_ratio",
19536 "unpreconditioned_norm", "true_norm", "relative_norm",
19537}
19538
19539
19540_SUMMARY_COUNT_FIELDS = {
19541 "total_particles", "lost_particles", "lost_particles_cumulative",
19542 "migrated_particles", "occupied_cells", "migration_passes", "calls",
19543 "pseudo_iterations", "newton_iterations", "iterations",
19544}
19545
19546
19547def _humanize_plot_identifier(value: str) -> str:
19548 """!
19549 @brief Convert one machine-oriented identifier into a readable plot label.
19550 @param[in] value Dotted path or snake-case identifier.
19551 @return Human-readable label.
19552 """
19553 text = str(value).split(".")[-1].replace("_", " ").strip()
19554 replacements = {
19555 "l2": "L²", "linf": "L∞", "msd": "mean-squared displacement",
19556 "pct": "percentage", "p95": "95th percentile", "cfl": "CFL",
19557 "ke": "kinetic energy", "rms": "RMS",
19558 }
19559 words = [replacements.get(word.lower(), word) for word in text.split()]
19560 return " ".join(words[:1]).capitalize() + (" " + " ".join(words[1:]) if len(words) > 1 else "")
19561
19562
19563def _summary_field_label(field: str) -> str:
19564 """!
19565 @brief Return the report-facing label for one logged scalar field.
19566 @param[in] field Logged scalar field name.
19567 @return Report-facing label.
19568 """
19569 return _SUMMARY_PLOT_FIELD_LABELS.get(field, _humanize_plot_identifier(field))
19570
19571
19572def _summary_physical_time(context: dict, record: dict) -> "float | None":
19573 """!
19574 @brief Resolve a record's physical time from its artifact or copied case configuration.
19575 @param[in] context Summary context with copied case configuration.
19576 @param[in] record Collected plot record.
19577 @return Physical time, or None when it cannot be resolved.
19578 """
19579 explicit = record.get("coordinates", {}).get("time")
19580 if explicit is not None:
19581 return float(explicit)
19582 try:
19583 dt = float((context.get("case_cfg") or {}).get("run_control", {}).get("dt_physical"))
19584 except (TypeError, ValueError):
19585 return None
19586 return float(record["step"]) * dt if math.isfinite(dt) and dt > 0.0 else None
19587
19588
19590 """!
19591 @brief Yields `(segment, row)` for each data line of a runtime diagnostics CSV.
19592
19593 The solver's runtime CSVs share one shape: a `step,...` header written once, data
19594 rows appended across the run, and a comment marker at each seam where a continuation
19595 resumed. Rows before the header, or whose width disagrees with it, are skipped rather
19596 than guessed at. `segment` counts the continuations seen so far, so a caller can keep
19597 restarts as separate series instead of drawing a line across the seam.
19598
19599 @param path Diagnostics CSV to read.
19600 @return Generator of `(segment_index, {column: text})` pairs.
19601 """
19602 segment = 0
19603 header = []
19604 with open(path, "r", encoding="utf-8", errors="replace", newline="") as handle:
19605 for raw_line in handle:
19607 segment += 1
19608 continue
19609 if raw_line.lstrip().startswith("step,"):
19610 header = [name.strip() for name in raw_line.strip().split(",")]
19611 continue
19612 parts = [part.strip() for part in raw_line.strip().split(",")]
19613 if not header or len(parts) != len(header):
19614 continue
19615 yield segment, dict(zip(header, parts))
19616
19617
19618def _append_summary_plot_record(records: list, source: str, step, line: str, values: dict,
19619 source_path: str, segment: int = 0, coordinates: dict = None):
19620 """!
19621 @brief Append one numeric append-ordered record for summarize plotting.
19622 @param[out] records Destination record list.
19623 @param[in] source Qualified source prefix.
19624 @param[in] step Logged timestep.
19625 @param[in] line Human-readable line identity.
19626 @param[in] values Candidate field mapping.
19627 @param[in] source_path Source artifact path.
19628 @param[in] segment Zero-based continuation segment within the source artifact.
19629 @param[in] coordinates Optional independent variables carried by the source row.
19630 """
19631 numeric = {
19632 key: value
19633 for key, value in values.items()
19634 if isinstance(value, (int, float)) and not isinstance(value, bool)
19635 }
19636 if step is not None and numeric:
19637 records.append({
19638 "source": source,
19639 "step": int(step),
19640 "line": line,
19641 "values": numeric,
19642 "source_path": source_path,
19643 "segment": int(segment),
19644 "coordinates": {
19645 key: value for key, value in (coordinates or {}).items()
19646 if isinstance(value, (int, float)) and not isinstance(value, bool)
19647 },
19648 })
19649
19650
19652 """!
19653 @brief Return whether a log line starts a new continuation segment.
19654 @param[in] line Candidate raw or stripped log line.
19655 @return True for the shared continuation marker syntax.
19656 """
19657 return bool(re.match(r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
19658
19659
19660def _collect_summary_plot_records(context: dict) -> list:
19661 """!
19662 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
19663 @param[in] context Summary context returned by `_build_summary_context()`.
19664 @return Append-ordered plot record list.
19665 """
19666 records = []
19667 log_dir = context["log_dir"]
19668 # The C runtime writes its per-step diagnostics CSVs to the analysis metrics home,
19669 # not beside the text logs; PicurvOpenDiagnosticsCsv() owns that placement.
19670 metrics_dir = context["metrics_dir"]
19671
19672 continuity_path = os.path.join(log_dir, "Continuity_Metrics.log")
19673 if os.path.isfile(continuity_path):
19674 segment = 0
19675 with open(continuity_path, "r", encoding="utf-8", errors="replace") as f:
19676 for raw_line in f:
19678 segment += 1
19679 continue
19680 parts = [part.strip() for part in raw_line.split("|")]
19681 if len(parts) < 8:
19682 continue
19683 step, block = _parse_int_loose(parts[0]), _parse_int_loose(parts[1])
19685 records, "continuity", step, f"block {block}",
19686 {
19687 "max_divergence": _parse_float_loose(parts[2]),
19688 "rhs_sum": _parse_float_loose(parts[4]),
19689 "flux_in": _parse_float_loose(parts[5]),
19690 "flux_out": _parse_float_loose(parts[6]),
19691 "net_flux": _parse_float_loose(parts[7]),
19692 },
19693 continuity_path, segment,
19694 )
19695
19696 particle_path = os.path.join(log_dir, "Particle_Metrics.log")
19697 if os.path.isfile(particle_path):
19698 segment = 0
19699 with open(particle_path, "r", encoding="utf-8", errors="replace") as f:
19700 for raw_line in f:
19702 segment += 1
19703 continue
19704 parts = [part.strip() for part in raw_line.split("|")]
19705 if len(parts) < 8:
19706 continue
19707 step = _parse_int_loose(parts[1])
19708 offset = 1 if len(parts) >= 9 else 0
19710 records, "particles", step, "particles",
19711 {
19712 "total_particles": _parse_int_loose(parts[2]),
19713 "lost_particles": _parse_int_loose(parts[3]),
19714 "lost_particles_cumulative": _parse_int_loose(parts[4]) if offset else None,
19715 "migrated_particles": _parse_int_loose(parts[4 + offset]),
19716 "occupied_cells": _parse_int_loose(parts[5 + offset]),
19717 "load_imbalance": _parse_float_loose(parts[6 + offset]),
19718 "migration_passes": _parse_int_loose(parts[7 + offset]),
19719 },
19720 particle_path, segment,
19721 )
19722
19723 # Phase 3+: dtau [physical time] + cfl_eff [dimensionless Courant number].
19724 momentum_regex = re.compile(
19725 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\s*\|"
19726 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
19727 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
19728 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
19729 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
19730 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
19731 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
19732 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
19733 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
19734 )
19735 jameson_patterns = [
19736 os.path.join(log_dir, "Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
19737 os.path.join(log_dir, "Momentum_Solver_Convergence_History_Block_*.log"),
19738 ]
19739 for path in sorted(path for pattern in jameson_patterns for path in glob.glob(pattern)):
19740 block_match = re.search(r"Block_(\d+)\.log$", path)
19741 if not block_match:
19742 continue
19743 segment = 0
19744 with open(path, "r", encoding="utf-8", errors="replace") as f:
19745 for raw_line in f:
19747 segment += 1
19748 continue
19749 match = momentum_regex.search(raw_line)
19750 if match:
19752 records, "momentum", int(match.group("step")), f"block {block_match.group(1)}",
19753 {
19754 "pseudo_iterations": int(match.group("pseudo_iter")),
19755 "dtau": _parse_float_loose(match.group("dtau")),
19756 "cfl_eff": _parse_float_loose(match.group("cfl_eff")),
19757 "delta_norm": float(match.group("delta")),
19758 "delta_rel": float(match.group("delta_rel")),
19759 "residual_norm": float(match.group("resid")),
19760 "residual_rel": float(match.group("resid_rel")),
19761 "trial_ratio": _parse_float_loose(match.group("trial_ratio")),
19762 "smoothed_ratio": _parse_float_loose(match.group("smoothed_ratio")),
19763 "dtau_after": _parse_float_loose(match.group("dtau_after")),
19764 "cfl_eff_after": _parse_float_loose(match.group("cfl_eff_after")),
19765 },
19766 path, segment,
19767 coordinates={"solver_iteration": int(match.group("pseudo_iter"))},
19768 )
19769
19770 newton_history_regex = re.compile(
19771 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
19772 r"\s*newton:\s*(?P<newton>\d+)\s*\|\s*nonlinear_norm:\s*(?P<norm>[-+0-9.eE]+)"
19773 )
19774 for path in sorted(glob.glob(os.path.join(log_dir, "Momentum_Solver_Newton_Krylov_History_Block_*.log"))):
19775 segment = 0
19776 with open(path, "r", encoding="utf-8", errors="replace") as f:
19777 for raw_line in f:
19779 segment += 1
19780 continue
19781 match = newton_history_regex.search(raw_line)
19782 if match:
19784 records, "momentum", int(match.group("step")), f"block {match.group('block')}",
19785 {
19786 "newton_iterations": int(match.group("newton")),
19787 "residual_norm": float(match.group("norm")),
19788 },
19789 path, segment,
19790 coordinates={"solver_iteration": int(match.group("newton"))},
19791 )
19792
19793 poisson_regex = re.compile(
19794 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
19795 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
19796 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
19797 )
19798 for path in sorted(glob.glob(os.path.join(log_dir, "Poisson_Solver_Convergence_History_Block_*.log"))):
19799 segment = 0
19800 with open(path, "r", encoding="utf-8", errors="replace") as f:
19801 for raw_line in f:
19803 segment += 1
19804 continue
19805 match = poisson_regex.search(raw_line)
19806 if match:
19808 records, "poisson", int(match.group("step")), f"block {match.group('block')}",
19809 {
19810 "iterations": int(match.group("iter")),
19811 "unpreconditioned_norm": float(match.group("unpre")),
19812 "true_norm": float(match.group("true")),
19813 "relative_norm": _parse_float_loose(match.group("rel")),
19814 },
19815 path, segment,
19816 coordinates={"solver_iteration": int(match.group("iter"))},
19817 )
19818
19819 # The LES coefficient history. cs_effective is the curve an LES run is judged on:
19820 # for decaying isotropic turbulence it should settle near Lilly's 0.16-0.17.
19821 les_path = os.path.join(metrics_dir, "les_coefficient.csv")
19822 if os.path.isfile(les_path):
19823 for segment, row in _read_runtime_diagnostics_csv(les_path):
19825 records, "les", _parse_int_loose(row.get("step")), "coefficient",
19826 {
19827 "cs_effective": _parse_float_loose(row.get("cs_effective")),
19828 "cs_mean": _parse_float_loose(row.get("cs_mean")),
19829 "coefficient_rms": _parse_float_loose(row.get("coefficient_rms")),
19830 "coefficient_min": _parse_float_loose(row.get("coefficient_min")),
19831 "coefficient_max": _parse_float_loose(row.get("coefficient_max")),
19832 "nu_t_mean": _parse_float_loose(row.get("nu_t_mean")),
19833 "nu_t_max": _parse_float_loose(row.get("nu_t_max")),
19834 "nu_t_over_nu_mean": _parse_float_loose(row.get("nu_t_over_nu_mean")),
19835 "k_sgs_mean": _parse_float_loose(row.get("k_sgs_mean")),
19836 "backscatter_fraction": _parse_float_loose(row.get("backscatter_fraction")),
19837 "limited_fraction": _parse_float_loose(row.get("limited_fraction")),
19838 },
19839 les_path, segment,
19840 )
19841
19842 # The wall-model history. y_plus_mean is what says whether the first cell sits where
19843 # the selected law is valid; u_tau is what a channel run is scored against.
19844 wall_path = os.path.join(metrics_dir, "wall_model.csv")
19845 if os.path.isfile(wall_path):
19846 for segment, row in _read_runtime_diagnostics_csv(wall_path):
19848 records, "wall_model", _parse_int_loose(row.get("step")), "near wall",
19849 {
19850 "u_tau_mean": _parse_float_loose(row.get("u_tau_mean")),
19851 "u_tau_rms": _parse_float_loose(row.get("u_tau_rms")),
19852 "u_tau_min": _parse_float_loose(row.get("u_tau_min")),
19853 "u_tau_max": _parse_float_loose(row.get("u_tau_max")),
19854 "y_plus_mean": _parse_float_loose(row.get("y_plus_mean")),
19855 "y_plus_max": _parse_float_loose(row.get("y_plus_max")),
19856 "wall_distance_mean": _parse_float_loose(row.get("wall_distance_mean")),
19857 "nu_wall_over_nu_mean": _parse_float_loose(row.get("nu_wall_over_nu_mean")),
19858 "wall_cells": _parse_int_loose(row.get("wall_cells")),
19859 },
19860 wall_path, segment,
19861 )
19862
19863 profiling_path = os.path.join(log_dir, context["profiling_cfg"].get("timestep_file", "Profiling_Timestep_Summary.csv"))
19864 if os.path.isfile(profiling_path):
19865 segment = 0
19866 columns = None
19867 with open(profiling_path, "r", encoding="utf-8", errors="replace", newline="") as f:
19868 for raw_line in f:
19870 segment += 1
19871 continue
19872 values = next(csv.reader([raw_line]))
19873 if not values:
19874 continue
19875 if columns is None:
19876 columns = values
19877 continue
19878 row = dict(zip(columns, values))
19880 records, "profiling", _parse_int_loose(row.get("step")), row.get("function") or "unknown",
19881 {"calls": _parse_int_loose(row.get("calls")), "step_time_s": _parse_float_loose(row.get("step_time_s"))},
19882 profiling_path, segment,
19883 )
19884
19885 diagnostics = resolve_diagnostics_config(context["monitor_cfg"])
19886 memory_path = os.path.join(log_dir, diagnostics["runtime_memory_log"].get("file", "Runtime_Memory.log"))
19887 if os.path.isfile(memory_path):
19888 segment = 0
19889 with open(memory_path, "r", encoding="utf-8", errors="replace") as f:
19890 for raw_line in f:
19892 segment += 1
19893 continue
19894 parts = raw_line.split()
19895 if len(parts) >= 8 and parts[1] in {"Step", "Post"}:
19897 records, "memory", _parse_int_loose(parts[0]), "memory",
19898 {
19899 "process_current_mb_max": _parse_float_loose(parts[2]),
19900 "process_peak_mb_max": _parse_float_loose(parts[3]),
19901 "petsc_allocated_mb_max": _parse_float_loose(parts[4]),
19902 "petsc_peak_allocated_mb_max": _parse_float_loose(parts[5]),
19903 "process_change_mb_max": _parse_float_loose(parts[6]),
19904 },
19905 memory_path, segment,
19906 )
19907
19908 convergence_path = os.path.join(log_dir, "solution_convergence.log")
19909 if os.path.isfile(convergence_path):
19910 columns = None
19911 segment = 0
19912 with open(convergence_path, "r", encoding="utf-8", errors="replace") as f:
19913 for raw_line in f:
19914 line = raw_line.strip()
19916 segment += 1
19917 continue
19918 if not line or line.startswith(("=", "-")):
19919 continue
19920 if columns is None:
19921 columns = [part.strip() for part in raw_line.split("|")]
19922 continue
19923 parts = [part.strip() for part in raw_line.split("|")]
19924 step = _parse_int_loose(parts[0])
19925 row = dict(zip(columns, parts))
19926 values = {
19927 name: _parse_float_loose(value)
19928 for name, value in row.items()
19929 if name not in {"step", "time", "mode", "ref", "ph", "per", "win"}
19930 }
19932 records, "convergence", step, "convergence", values,
19933 convergence_path, segment,
19934 coordinates={"time": _parse_float_loose(row.get("time"))},
19935 )
19936
19937 records.extend(_collect_spectra_plot_records(context))
19938 return records
19939
19940
19941def _collect_spectra_plot_records(context: dict) -> list:
19942 """!
19943 @brief Collect the per-step scalar histories written by the spectra post stage.
19944
19945 @details Each spectra task keeps its own history file, so one task becomes one
19946 plotted line and several tasks compare directly on the same axes.
19947
19948 @param[in] context Summary context returned by `_build_summary_context()`.
19949 @return Append-ordered plot record list; empty when no spectra were measured.
19950 """
19951 records = []
19952 run_dir = context.get("run_dir")
19953 if not run_dir:
19954 return records
19955 spectra_root = os.path.join(
19956 run_dir, resolve_post_spectra_output_dir(context.get("monitor_cfg") or {})
19957 )
19958 for root, _dirs, files in os.walk(spectra_root):
19959 for filename in sorted(files):
19960 if not filename.endswith("_history.csv"):
19961 continue
19962 history_path = os.path.join(root, filename)
19963 line = filename[: -len("_history.csv")]
19964 try:
19965 with open(history_path, "r", encoding="utf-8", errors="replace") as stream:
19966 for row in csv.DictReader(stream):
19967 step = _parse_int_loose(row.get("step"))
19968 if step is None:
19969 continue
19970 values = {}
19971 for column in POST_SPECTRA_SCALAR_COLUMNS:
19972 parsed = _parse_float_loose(row.get(column))
19973 if parsed is not None:
19974 values[column] = parsed
19975 if values:
19977 records, "spectra", step, line, values, history_path, 0,
19978 coordinates={"time": _parse_float_loose(row.get("time"))},
19979 )
19980 except OSError:
19981 # A history a concurrent post stage is still writing is simply not
19982 # listed yet; summarize is read-only and must never fail on it.
19983 continue
19984 return records
19985
19986
19987def _build_summary_plot_catalog(records: list) -> list:
19988 """!
19989 @brief Build available qualified-series metadata from plot records.
19990 @param[in] records Append-ordered plot record list.
19991 @return Available series catalog.
19992 """
19993 catalog = {}
19994 for record in records:
19995 for field in record["values"]:
19996 name = f"{record['source']}.{field}"
19997 item = catalog.setdefault(name, {"series": name, "lines": {}, "source_paths": set(), "sample_count": 0})
19998 item["lines"][record["line"]] = item["lines"].get(record["line"], 0) + 1
19999 item["source_paths"].add(record["source_path"])
20000 item["sample_count"] += 1
20001 return [
20002 {
20003 **item,
20004 "lines": [{"label": label, "sample_count": count} for label, count in sorted(item["lines"].items())],
20005 "source_paths": sorted(item["source_paths"]),
20006 }
20007 for _, item in sorted(catalog.items())
20008 ]
20009
20010
20011def _representative_indices(count: int, maximum: int = 6) -> list:
20012 """!
20013 @brief Select evenly distributed indices while always retaining both endpoints.
20014 @param[in] count Number of available ordered states.
20015 @param[in] maximum Maximum states to retain.
20016 @return Sorted unique zero-based indices.
20017 """
20018 if count <= maximum:
20019 return list(range(count))
20020 return sorted({round(index * (count - 1) / (maximum - 1)) for index in range(maximum)})
20021
20022
20023def _build_spectrum_plot_request(context: dict, task: str, reference: bool,
20024 linear_y: bool, output_path: "str | None") -> dict:
20025 """!
20026 @brief Build a plot.gen request drawing representative measured spectra.
20027
20028 @details A decaying flow has a different spectrum at every step, so states are
20029 never averaged. Up to six evenly spaced states are retained, including
20030 the first and last, to show evolution without an unreadable legend.
20031
20032 @param[in] context Summary context returned by `_build_summary_context()`.
20033 @param[in] task Task basename, or a unique substring of one.
20034 @param[in] reference Overlay the staged initial-condition spectrum when available.
20035 @param[in] linear_y Force linear axes instead of the log-log default.
20036 @param[in] output_path Optional explicit output path.
20037 @return Versioned normalized plot request.
20038 @throws ValueError when no matching spectrum file exists.
20039 """
20040 spectra_dir = os.path.join(
20041 context["run_dir"],
20042 resolve_post_spectra_output_dir(context.get("monitor_cfg") or {}),
20043 )
20044 candidates = []
20045 if os.path.isdir(spectra_dir):
20046 # The staged initial-condition spectrum shares this directory but is the
20047 # reference overlay, never a task a user can select.
20048 reference_name = os.path.basename(INITIAL_CONDITION_SPECTRUM_RELPATH)
20049 candidates = sorted(
20050 name for name in os.listdir(spectra_dir)
20051 if name.endswith(".csv")
20052 and not name.endswith("_history.csv")
20053 and name != reference_name
20054 )
20055 if not candidates:
20056 raise ValueError(
20057 "No spectra were found for this run. Run "
20058 "'picurv run --post-process --only spectra' first."
20059 )
20060 matches = [name for name in candidates if task in name] if task else candidates
20061 if len(matches) != 1:
20062 available = ", ".join(name[: -len(".csv")] for name in candidates)
20063 raise ValueError(
20064 f"Spectrum selector {task!r} matched {len(matches)} files. Available: {available}."
20065 )
20066 spectrum_path = os.path.join(spectra_dir, matches[0])
20067
20068 by_step = {}
20069 times = {}
20070 with open(spectrum_path, "r", encoding="utf-8", errors="replace") as stream:
20071 for row in csv.DictReader(stream):
20072 step = _parse_int_loose(row.get("step"))
20073 wavenumber = _parse_float_loose(row.get("k"))
20074 energy = _parse_float_loose(row.get("energy"))
20075 if step is None or wavenumber is None or energy is None:
20076 continue
20077 # A log-log plot cannot carry the zero wavenumber or an empty shell.
20078 if wavenumber <= 0.0 or (not linear_y and energy <= 0.0):
20079 continue
20080 by_step.setdefault(step, []).append([wavenumber, energy])
20081 times.setdefault(step, _parse_float_loose(row.get("time")))
20082 if not by_step:
20083 raise ValueError(f"{os.path.relpath(spectrum_path)} carries no plottable spectrum rows.")
20084
20085 lines = []
20086 if reference:
20087 reference_path = os.path.join(context["run_dir"], INITIAL_CONDITION_SPECTRUM_RELPATH)
20088 if os.path.isfile(reference_path):
20089 points = []
20090 with open(reference_path, "r", encoding="utf-8", errors="replace") as stream:
20091 for row in csv.DictReader(stream):
20092 wavenumber = _parse_float_loose(row.get("k"))
20093 energy = _parse_float_loose(row.get("energy"))
20094 if wavenumber is None or energy is None:
20095 continue
20096 if wavenumber <= 0.0 or (not linear_y and energy <= 0.0):
20097 continue
20098 points.append([wavenumber, energy])
20099 if points:
20100 lines.append({
20101 "label": "Initial condition", "points": sorted(points),
20102 "role": "reference", "color": "#333333", "line_style": "--",
20103 })
20104
20105 ordered_steps = sorted(by_step)
20106 selected_steps = [ordered_steps[index] for index in _representative_indices(len(ordered_steps))]
20107 spectrum_colors = ("#440154", "#414487", "#2A788E", "#22A884", "#7AD151", "#DCE319")
20108 for index, step in enumerate(selected_steps):
20109 time = times.get(step)
20110 label = f"Step {step}" if time is None else f"t = {time:g} (step {step})"
20111 lines.append({
20112 "label": label,
20113 "points": sorted(by_step[step]),
20114 "role": "latest" if step == ordered_steps[-1] else "snapshot",
20115 "color": spectrum_colors[round(index * (len(spectrum_colors) - 1) / max(1, len(selected_steps) - 1))],
20116 "line_width": 2.4 if step == ordered_steps[-1] else 1.65,
20117 })
20118
20119 name = matches[0][: -len(".csv")]
20120 fallback = os.path.join(
20121 context["run_dir"], CANONICAL_RUN_PATHS["plots"], f"{name}.png"
20122 )
20123 task_match = re.search(r"_(?P<field>[^_]+)_block(?P<block>\d+)_(?P<symbol>[^_]+)$", name)
20124 subtitle = None
20125 if task_match:
20126 subtitle = (
20127 f"Field {task_match.group('field')} · block {int(task_match.group('block'))} "
20128 f"· {task_match.group('symbol')} wavenumber"
20129 )
20130 return {
20131 "schema_version": 1,
20132 "plot_type": "spectrum",
20133 "series": name,
20134 "title": "Turbulent kinetic-energy spectrum",
20135 "subtitle": subtitle,
20136 "x_label": "Wavenumber, k",
20137 "y_label": "Energy spectrum, E(k)",
20138 "x_scale": "linear" if linear_y else "log",
20139 "y_scale": "linear" if linear_y else "log",
20140 "legend_title": "Snapshot",
20141 "show_markers": False,
20142 "window": {
20143 "mode": "representative", "last": None,
20144 "available_steps": len(ordered_steps), "selected_steps": selected_steps,
20145 },
20146 "lines": lines,
20147 "output_path": os.path.abspath(output_path) if output_path else fallback,
20148 }
20149
20150
20151def _build_summary_plot_request(context: dict, records: list, series: str, last_n: "int | None", linear_y: bool, output_path: "str | None") -> dict:
20152 """!
20153 @brief Build one normalized plot.gen request from collected summarize records.
20154 @param[in] context Summary context returned by `_build_summary_context()`.
20155 @param[in] records Append-ordered plot record list.
20156 @param[in] series Qualified series name.
20157 @param[in] last_n Optional last-N records per plotted line.
20158 @param[in] linear_y Whether to force linear scaling.
20159 @param[in] output_path Optional explicit output path.
20160 @return Versioned normalized plot request.
20161 """
20162 source, separator, field = series.partition(".")
20163 if not separator:
20164 raise ValueError("plot series must be qualified as '<source>.<field>'")
20165 matching = [record for record in records if record["source"] == source and field in record["values"]]
20166 if not matching:
20167 raise ValueError(f"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
20168 latest_segments = {}
20169 for record in matching:
20170 source_path = record["source_path"]
20171 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get("segment", 0))
20172 matching = [
20173 record for record in matching
20174 if record.get("segment", 0) == latest_segments[record["source_path"]]
20175 ]
20176 is_iteration_history = (
20177 source in {"momentum", "poisson"} and field in _SUMMARY_ITERATION_HISTORY_FIELDS
20178 and any("solver_iteration" in record.get("coordinates", {}) for record in matching)
20179 )
20180 grouped = {}
20181 selected_steps = set()
20182 if is_iteration_history:
20183 histories = {}
20184 for record in matching:
20185 iteration = record.get("coordinates", {}).get("solver_iteration")
20186 if iteration is None:
20187 continue
20188 histories.setdefault((record["source_path"], record["line"]), []).append(record)
20189 for (_source_path, label), history in histories.items():
20190 latest_step = max(record["step"] for record in history)
20191 selected_steps.add(latest_step)
20192 points = [
20193 [record["coordinates"]["solver_iteration"], record["values"][field]]
20194 for record in history if record["step"] == latest_step
20195 ]
20196 if last_n is not None:
20197 points = points[-last_n:]
20198 grouped.setdefault(f"{label} · step {latest_step}", []).extend(points)
20199 x_label = "Nonlinear iteration" if source == "momentum" else "Linear iteration"
20200 x_kind = "integer"
20201 else:
20202 uses_physical_time = all(_summary_physical_time(context, record) is not None for record in matching)
20203 for record in matching:
20204 x_value = _summary_physical_time(context, record) if uses_physical_time else record["step"]
20205 grouped.setdefault(record["line"], []).append([x_value, record["values"][field]])
20206 # Iteration-count fields occur on every nonlinear-history row. Reduce them
20207 # to the final effort per physical step instead of drawing a y=x staircase.
20208 if field in {"pseudo_iterations", "newton_iterations", "iterations"}:
20209 grouped = {
20210 label: [list(item) for item in {
20211 point[0]: max(candidate[1] for candidate in points if candidate[0] == point[0])
20212 for point in points
20213 }.items()]
20214 for label, points in grouped.items()
20215 }
20216 for label, points in grouped.items():
20217 points.sort(key=lambda point: point[0])
20218 if last_n is not None:
20219 grouped[label] = points[-last_n:]
20220 x_label = "Physical time" if uses_physical_time else "Physical timestep"
20221 x_kind = "continuous" if uses_physical_time else "integer"
20222
20223 grouped = {label: points for label, points in grouped.items() if points}
20224 if not grouped:
20225 raise ValueError(f"Plot series '{series}' has no plottable points in the selected window.")
20226 if is_iteration_history and len(selected_steps) == 1:
20227 grouped = {
20228 label.rsplit(" · step ", 1)[0]: points
20229 for label, points in grouped.items()
20230 }
20231 all_values = [point[1] for points in grouped.values() for point in points]
20232 use_log = not linear_y and field in _SUMMARY_PLOT_LOG_SCALE_FIELDS and all(value > 0 for value in all_values)
20233 window_token = f"last-{last_n}" if last_n is not None else "full"
20234 safe_series = re.sub(r"[^A-Za-z0-9_.-]+", "_", series)
20235 fallback = os.path.join(
20236 context["run_dir"], CANONICAL_RUN_PATHS["plots"],
20237 f"{safe_series}_{window_token}.png",
20238 )
20239 field_label = _summary_field_label(field)
20240 source_title = _SUMMARY_PLOT_SOURCE_TITLES.get(source, _humanize_plot_identifier(source))
20241 title = f"{source_title}: {field_label}"
20242 if is_iteration_history:
20243 title += " convergence"
20244 subtitle_bits = []
20245 if selected_steps:
20246 ordered = sorted(selected_steps)
20247 subtitle_bits.append(
20248 f"Latest physical step {ordered[0]}" if len(ordered) == 1
20249 else f"Latest available physical steps {ordered[0]}–{ordered[-1]}"
20250 )
20251 if last_n is not None:
20252 subtitle_bits.append(f"Last {last_n} samples per curve")
20253 legend_title = {
20254 "continuity": "Block",
20255 "momentum": "Block",
20256 "poisson": "Block",
20257 "profiling": "Function",
20258 "spectra": "Spectrum task",
20259 }.get(source, "Series")
20260 only_label = next(iter(grouped)) if len(grouped) == 1 else None
20261 show_legend = len(grouped) > 1 or bool(
20262 only_label and (only_label.lower().startswith("block ") or source == "profiling")
20263 )
20264 return {
20265 "schema_version": 1,
20266 "plot_type": "iteration_history" if is_iteration_history else "time_history",
20267 "series": series,
20268 "title": title,
20269 "subtitle": " · ".join(subtitle_bits) or None,
20270 "x_label": x_label,
20271 "x_kind": x_kind,
20272 "y_label": field_label,
20273 "y_scale": "log" if use_log else "linear",
20274 "include_zero_y": not use_log and all(value >= 0.0 for value in all_values),
20275 "show_markers": not is_iteration_history,
20276 "show_legend": show_legend,
20277 "legend_title": legend_title,
20278 "window": {"mode": "last" if last_n is not None else "full", "last": last_n},
20279 "lines": [{"label": label, "points": points} for label, points in sorted(grouped.items())],
20280 "output_path": os.path.abspath(output_path) if output_path else None,
20281 "fallback_output_path": fallback,
20282 }
20283
20284
20285def _render_summary_plot_catalog(catalog: list, output_format: str):
20286 """!
20287 @brief Render available summarize plot-series metadata.
20288 @param[in] catalog Available series catalog.
20289 @param[in] output_format Text or JSON output format.
20290 """
20291 if output_format == "json":
20292 print(json.dumps({"available_series": catalog}, indent=2, sort_keys=True))
20293 return
20294 print("\nAVAILABLE PLOT SERIES")
20295 print("=" * 78)
20296 for item in catalog:
20297 labels = ", ".join(line["label"] for line in item["lines"])
20298 print(f" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
20299 print(f" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
20300
20301
20302def _invoke_plot_gen(request: dict):
20303 """!
20304 @brief Invoke standalone plot.gen with one normalized request over stdin.
20305 @param[in] request Versioned normalized plot request.
20306 """
20307 plotgen_path = os.path.join(GENERATORS_PATH, "plot.gen")
20308 if not os.path.isfile(plotgen_path):
20309 raise ValueError(f"plot.gen script not found: {plotgen_path}")
20310 result = subprocess.run(
20311 [sys.executable, plotgen_path, "--input", "-"],
20312 input=json.dumps(request),
20313 text=True,
20314 capture_output=True,
20315 check=False,
20316 )
20317 if result.stdout:
20318 print(result.stdout.rstrip())
20319 if result.returncode != 0:
20320 details = (result.stderr or result.stdout or "unknown plotting error").strip()
20321 if result.returncode == 3:
20322 raise PlotDependencyError(details)
20323 raise ValueError(f"plot.gen failed with exit code {result.returncode}: {details}")
20324
20325
20327 """!
20328 @brief Build and render a read-only health summary for a run step.
20329 @param[in] args Command-line style argument list supplied to the function.
20330 """
20331 if args.step is not None and args.step < 0:
20332 fail_cli_usage("--step must be a non-negative integer.")
20333 if args.snapshot_rows < 1:
20334 fail_cli_usage("--snapshot-rows must be at least 1.")
20335 plot_series = getattr(args, "plot_series", None)
20336 list_plot_series = bool(getattr(args, "list_plot_series", False))
20337 plot_spectrum = getattr(args, "plot_spectrum", None)
20338 last_n = getattr(args, "last_n", None)
20339 plot_output = getattr(args, "plot_output", None)
20340 linear_y = bool(getattr(args, "linear_y", False))
20341 plot_mode = bool(plot_series or list_plot_series or plot_spectrum is not None)
20342 existing_selectors = any(
20343 [
20344 getattr(args, "overview", False),
20345 getattr(args, "case", False),
20346 getattr(args, "solver", False),
20347 getattr(args, "monitor", False),
20348 args.step is not None,
20349 getattr(args, "latest", False),
20350 getattr(args, "max_step", False),
20351 ]
20352 )
20353 if plot_mode and existing_selectors:
20354 fail_cli_usage("Plot discovery and --plot cannot be combined with config or selected-step selectors.")
20355 if not plot_series and last_n is not None:
20356 fail_cli_usage("--last requires --plot.")
20357 if not plot_series and plot_spectrum is None and (plot_output or linear_y):
20358 fail_cli_usage("--plot-output and --linear-y require --plot or --plot-spectrum.")
20359 if last_n is not None and last_n < 1:
20360 fail_cli_usage("--last must be a positive integer.")
20361 if plot_series and args.output_format == "json":
20362 fail_cli_usage("--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
20363 if plot_mode:
20364 context = _build_summary_context(args.run_dir)
20365 try:
20366 records = _collect_summary_plot_records(context)
20367 catalog = _build_summary_plot_catalog(records)
20368 if list_plot_series:
20369 if not catalog:
20370 raise ValueError("No plottable scalar histories were found in the run logs.")
20371 _render_summary_plot_catalog(catalog, args.output_format)
20372 return
20373 if plot_spectrum is not None:
20375 context, plot_spectrum, True, linear_y, plot_output
20376 )
20377 else:
20379 context, records, plot_series, last_n, linear_y, plot_output
20380 )
20381 _invoke_plot_gen(request)
20382 return
20383 except PlotDependencyError as exc:
20385 ERROR_CODE_DEPENDENCY_MISSING,
20386 key="plotting",
20387 file_path=sys.executable,
20388 message=str(exc),
20389 )
20390 sys.exit(1)
20391 except ValueError as exc:
20393 ERROR_CODE_CFG_INVALID_VALUE,
20394 key="plot",
20395 file_path=context["log_dir"],
20396 message=str(exc),
20397 )
20398 sys.exit(1)
20399
20400 selected_configs = {
20401 name
20402 for name in ("case", "solver", "monitor")
20403 if bool(getattr(args, name, False))
20404 }
20405 if getattr(args, "overview", False):
20406 selected_configs.update({"case", "solver", "monitor"})
20407 explicit_health = args.step is not None or bool(getattr(args, "latest", False)) or bool(getattr(args, "max_step", False))
20408 health_requested = explicit_health or (not selected_configs and not getattr(args, "overview", False))
20409
20410 context = None
20411 combined = {"storage": storage_state_summary(args.run_dir)}
20412 if selected_configs or getattr(args, "overview", False):
20413 context = _build_summary_context(args.run_dir)
20414 if getattr(args, "overview", False):
20415 combined["run_overview"] = _build_run_overview(context)
20416 combined["configuration"] = {}
20417 builders = {
20418 "case": _build_case_overview,
20419 "solver": _build_solver_overview,
20420 "monitor": _build_monitor_overview,
20421 }
20422 for name in ("case", "solver", "monitor"):
20423 if name in selected_configs:
20424 try:
20425 combined["configuration"][name] = builders[name](context)
20426 except (KeyError, TypeError, ValueError, ZeroDivisionError) as exc:
20428 ERROR_CODE_CFG_INVALID_VALUE,
20429 key=name,
20430 file_path=context["config_paths"][name],
20431 message=f"Could not summarize copied {name}.yml: {exc}",
20432 )
20433 sys.exit(1)
20434
20435 if not health_requested:
20436 combined["_health_requested"] = False
20437 render_selected_summary(combined, output_format=args.output_format)
20438 return
20439
20440 requested_step = args.step
20441 if requested_step is None and getattr(args, "latest", False):
20442 requested_step = None
20443 selection_mode = "max_step" if getattr(args, "max_step", False) else "latest"
20444 health_payload = build_run_summary_payload(
20445 args.run_dir,
20446 step=requested_step,
20447 snapshot_rows=args.snapshot_rows,
20448 selection_mode=selection_mode,
20449 )
20450 if set(combined) == {"storage"}:
20451 combined = {**health_payload, **combined, "_health_requested": True}
20452 render_selected_summary(combined, output_format=args.output_format)
20453 return
20454 combined = {**health_payload, **combined, "_health_requested": True}
20455 render_selected_summary(combined, output_format=args.output_format)
20456
20457
20458def _resolve_submission_target(run_dir: str = None, study_dir: str = None) -> dict:
20459 """!
20460 @brief Resolve a run/study submission target from explicit directory flags.
20461 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
20462 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
20463 @return Value returned by `_resolve_submission_target()`.
20464 """
20465 has_run_dir = bool(run_dir)
20466 has_study_dir = bool(study_dir)
20467 if has_run_dir == has_study_dir:
20468 fail_cli_usage("submit requires exactly one of --run-dir or --study-dir.")
20469
20470 target_kind = "run" if has_run_dir else "study"
20471 target_key = "run_dir" if target_kind == "run" else "study_dir"
20472 root_dir = os.path.abspath(run_dir if has_run_dir else study_dir)
20473 if not os.path.isdir(root_dir):
20475 ERROR_CODE_CFG_FILE_NOT_FOUND,
20476 key=target_key,
20477 file_path=root_dir,
20478 message=f"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
20479 )
20480 sys.exit(1)
20481
20482 scheduler_dir = os.path.join(root_dir, "scheduler")
20483 submission_path = os.path.join(scheduler_dir, "submission.json")
20484 submission_meta = _read_json_if_exists(submission_path)
20485 if not isinstance(submission_meta, dict):
20487 ERROR_CODE_CFG_FILE_NOT_FOUND,
20488 key="scheduler.submission",
20489 file_path=submission_path,
20490 message="Target directory does not contain scheduler submission metadata.",
20491 hint="Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
20492 )
20493 sys.exit(1)
20494
20495 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
20496 if launch_mode == "local" and target_kind != "run":
20498 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20499 key="scheduler.launch_mode",
20500 file_path=submission_path,
20501 message="Local staged submission is supported for run directories only.",
20502 hint="Use --run-dir for local staged execution; study submit remains Slurm-only.",
20503 )
20504 sys.exit(1)
20505 if launch_mode not in {"slurm", "local"}:
20507 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20508 key="scheduler.launch_mode",
20509 file_path=submission_path,
20510 message=f"Target launch_mode={launch_mode or 'unknown'} is not supported.",
20511 hint="Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
20512 )
20513 sys.exit(1)
20514
20515 if target_kind == "run":
20516 script_map = {
20517 "solve": os.path.join(scheduler_dir, "solver.sbatch"),
20518 "post-process": os.path.join(scheduler_dir, "post.sbatch"),
20519 }
20520 display_label = "Run directory"
20521 manifest_path = None
20522 else:
20523 script_map = {
20524 "solve": os.path.join(scheduler_dir, "solver_array.sbatch"),
20525 "post-process": os.path.join(scheduler_dir, "post_array.sbatch"),
20526 }
20527 display_label = "Study directory"
20528 manifest_path = os.path.join(root_dir, "study_manifest.json")
20529
20530 return {
20531 "target_kind": target_kind,
20532 "target_key": target_key,
20533 "root_dir": root_dir,
20534 "scheduler_dir": scheduler_dir,
20535 "submission_path": submission_path,
20536 "submission_meta": submission_meta,
20537 "launch_mode": launch_mode,
20538 "script_map": script_map,
20539 "display_label": display_label,
20540 "manifest_path": manifest_path,
20541 }
20542
20543
20544def _get_submission_stage_metadata(target_context: dict, stage_name: str) -> dict:
20545 """!
20546 @brief Return stored metadata for one staged submission target.
20547 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
20548 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
20549 @return Value returned by `_get_submission_stage_metadata()`.
20550 """
20551 submission_meta = target_context["submission_meta"]
20552 if target_context["target_kind"] == "run":
20553 stages = submission_meta.get("stages", {})
20554 if not isinstance(stages, dict):
20555 return {}
20556 stage_meta = stages.get(stage_name)
20557 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
20558
20559 key = "solver_array" if stage_name == "solve" else "post_array"
20560 stage_meta = submission_meta.get(key)
20561 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
20562
20563
20564def _get_recorded_submission_stages(target_context: dict) -> list:
20565 """!
20566 @brief Return stage names explicitly recorded in scheduler submission metadata.
20567 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
20568 @return Value returned by `_get_recorded_submission_stages()`.
20569 """
20570 submission_meta = target_context["submission_meta"]
20571 recorded = []
20572 if target_context["target_kind"] == "run":
20573 stages = submission_meta.get("stages", {})
20574 if isinstance(stages, dict):
20575 for stage_name in ["solve", "post-process"]:
20576 if isinstance(stages.get(stage_name), dict):
20577 recorded.append(stage_name)
20578 return recorded
20579
20580 if isinstance(submission_meta.get("solver_array"), dict):
20581 recorded.append("solve")
20582 if isinstance(submission_meta.get("post_array"), dict):
20583 recorded.append("post-process")
20584 return recorded
20585
20586
20587def _format_stage_list(stage_names: list) -> str:
20588 """!
20589 @brief Format a human-readable stage list for submit diagnostics.
20590 @param[in] stage_names Argument passed to `_format_stage_list()`.
20591 @return Value returned by `_format_stage_list()`.
20592 """
20593 return ", ".join(stage_names) if stage_names else "none"
20594
20595
20596def _build_submit_missing_stage_hint(target_context: dict, requested_stage: str, selected_stages: list) -> str:
20597 """!
20598 @brief Build an actionable hint for requested submit stages missing from metadata.
20599 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
20600 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
20601 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
20602 @return Value returned by `_build_submit_missing_stage_hint()`.
20603 """
20604 recorded_stages = _get_recorded_submission_stages(target_context)
20605 recorded_set = set(recorded_stages)
20606 selected_set = set(selected_stages)
20607 target_flag = "--run-dir" if target_context["target_kind"] == "run" else "--study-dir"
20608 target_path = os.path.relpath(target_context["root_dir"])
20609 submit_prefix = f"picurv submit {target_flag} {target_path}"
20610 solve_stage_command = (
20611 "picurv run --solve ... --no-submit"
20612 if target_context["target_kind"] == "run"
20613 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
20614 )
20615 post_stage_command = (
20616 "picurv run --post-process --post <post.yml> ... --no-submit"
20617 if target_context["target_kind"] == "run"
20618 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
20619 )
20620 solve_post_command = (
20621 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
20622 if target_context["target_kind"] == "run"
20623 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
20624 )
20625
20626 if requested_stage == "all":
20627 if recorded_set == {"solve"}:
20628 return (
20629 "--stage all requests solve and post-process, but this target records only solve. "
20630 f"Use `{submit_prefix} --stage solve`, or re-stage with post-processing enabled "
20631 f"(`{solve_post_command}`)."
20632 )
20633 if recorded_set == {"post-process"}:
20634 return (
20635 "--stage all requests solve and post-process, but this target records only post-process. "
20636 f"Use `{submit_prefix} --stage post-process`, or re-stage including the solve stage "
20637 f"(`{solve_stage_command}`)."
20638 )
20639 missing = [stage for stage in selected_stages if stage not in recorded_set]
20640 if missing:
20641 return (
20642 "--stage all requests solve and post-process, but submission metadata records "
20643 f"{_format_stage_list(recorded_stages)}. Re-stage the missing stage(s): "
20644 f"{_format_stage_list(missing)}."
20645 )
20646
20647 if selected_set == {"solve"} and "solve" not in recorded_set:
20648 return (
20649 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
20650 f"Re-stage with `{solve_stage_command}`."
20651 )
20652 if selected_set == {"post-process"} and "post-process" not in recorded_set:
20653 return (
20654 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
20655 f"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
20656 )
20657
20658 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
20659
20660
20661def _set_submission_stage_metadata(target_context: dict, stage_name: str, stage_meta: dict):
20662 """!
20663 @brief Persist one stage's metadata back into the submission payload.
20664 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
20665 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
20666 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
20667 """
20668 submission_meta = target_context["submission_meta"]
20669 if target_context["target_kind"] == "run":
20670 stages = submission_meta.get("stages")
20671 if not isinstance(stages, dict):
20672 stages = {}
20673 submission_meta["stages"] = stages
20674 stages[stage_name] = stage_meta
20675 return
20676
20677 key = "solver_array" if stage_name == "solve" else "post_array"
20678 submission_meta[key] = stage_meta
20679
20680
20681def _write_submission_target_metadata(target_context: dict):
20682 """!
20683 @brief Write updated submission metadata back to disk.
20684 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
20685 """
20686 write_json_file(target_context["submission_path"], target_context["submission_meta"])
20687
20688 manifest_path = target_context.get("manifest_path")
20689 if manifest_path and os.path.isfile(manifest_path):
20690 manifest_payload = _read_json_if_exists(manifest_path)
20691 if isinstance(manifest_payload, dict):
20692 manifest_payload["submission"] = target_context["submission_meta"]
20693 write_json_file(manifest_path, manifest_payload)
20694
20695
20696def staged_control_directories(control_path: str) -> tuple:
20697 """!
20698 @brief Read run-owned directory values from a staged control file.
20699
20700 @details Tokenizes with `shlex` because PETSc's options-file parser treats a
20701 double-quoted span as a single token. Splitting on whitespace would read
20702 `-log_dir "/tmp/VICTIM DIR"` as the value `"/tmp/VICTIM`, which looks
20703 relative and contained while PETSc would use the absolute path.
20704
20705 Malformed quoting is reported rather than skipped: a line the parser
20706 cannot interpret is exactly the case where preflight must not assume the
20707 run is safe.
20708 @param[in] control_path Path to a generated `.control` file.
20709 @return Tuple of (values, parse_errors).
20710 """
20711 flag_to_key = {
20712 "-log_dir": "log",
20713 "-output_dir": "output",
20714 "-restart_dir": "restart",
20715 "-analysis_dir": "analysis",
20716 }
20717 values: dict = {}
20718 parse_errors: list = []
20719 try:
20720 lines = read_text_file_lines(control_path)
20721 except OSError as exc:
20722 return values, [f"could not be read ({exc})"]
20723 for number, line in enumerate(lines, start=1):
20724 if not line.strip():
20725 continue
20726 try:
20727 tokens = shlex.split(line, comments=True)
20728 except ValueError as exc:
20729 parse_errors.append(
20730 f"line {number} has malformed quoting and cannot be interpreted ({exc}); "
20731 f"refusing to assume it is safe"
20732 )
20733 continue
20734 if tokens and tokens[0] in RESERVED_INDIRECTION_FLAGS:
20735 parse_errors.append(
20736 f"line {number} uses '{tokens[0]}', which PETSc expands itself; its contents "
20737 f"cannot be checked here and could set a run directory. Re-stage without it"
20738 )
20739 continue
20740 if len(tokens) >= 2 and tokens[0] in flag_to_key:
20741 values[flag_to_key[tokens[0]]] = tokens[1]
20742 return values, parse_errors
20743
20744
20745def preflight_config_directories(root_dir: str) -> list:
20746 """!
20747 @brief Every config directory under a run or study root that may hold a control file.
20748
20749 @details A study keeps its controls under `cases/<member>/config`, not at the study
20750 root, so a preflight that only looked at `<root>/config` was empty for every
20751 study. This walks the tree so members and nested runs are covered.
20752 @param[in] root_dir Run or study directory.
20753 @return Sorted config directory paths.
20754 """
20755 found: set = set()
20756 root = os.path.abspath(root_dir)
20757 direct = os.path.join(root, "config")
20758 if os.path.isdir(direct):
20759 found.add(direct)
20760 for current, dirnames, _ in os.walk(root):
20761 dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(current, d))]
20762 if os.path.basename(current) == "config" and glob.glob(os.path.join(current, "*.control")):
20763 found.add(current)
20764 return sorted(found)
20765
20766
20767def preflight_staged_run_directories(root_dir: str) -> tuple:
20768 """!
20769 @brief Re-check run-directory safety against an already-staged run or study.
20770
20771 @details Staging validates the configuration it is given, but a staged run can be
20772 edited, or produced by an older version, before submission. This re-reads the
20773 effective staged control files - across study members and nested runs - and
20774 applies the same rules as configuration validation, plus a physical
20775 containment check that a symlink cannot slip past.
20776 @param[in] root_dir Run or study directory being submitted.
20777 @return Tuple of (errors, warnings).
20778 """
20779 errors: list = []
20780 warnings: list = []
20781 config_dirs = preflight_config_directories(root_dir)
20782 if not config_dirs:
20783 return errors, warnings
20784 canonical = {
20785 "log": CANONICAL_RUN_PATHS["logs"],
20786 "output": CANONICAL_RUN_PATHS["output"],
20787 "restart": CANONICAL_RUN_PATHS["restart"],
20788 "analysis": CANONICAL_RUN_PATHS["metrics"],
20789 }
20790 for config_dir in config_dirs:
20791 run_root = os.path.dirname(config_dir)
20792 for control in sorted(glob.glob(os.path.join(config_dir, "*.control"))):
20793 staged, parse_errors = staged_control_directories(control)
20794 label = os.path.relpath(control)
20795 errors.extend(f" {label}: {message}" for message in parse_errors)
20796 if not staged:
20797 continue
20798 for key, expected in canonical.items():
20799 actual = staged.get(key)
20800 if actual is not None and normalized_run_directory(actual) != expected:
20801 errors.append(
20802 f" {label}: -{key}_dir is {actual!r}; the canonical value is "
20803 f"{expected!r}. Re-stage the run instead of editing its path flags."
20804 )
20805 effective = effective_run_directories(staged)
20806 control_errors, control_warnings = evaluate_run_directories(
20807 effective, False, explicit=set(staged)
20808 )
20809 errors.extend(f" {label}: {message}" for message in control_errors)
20810 warnings.extend(f" {label}: {message}" for message in control_warnings)
20811 for _, verdict, message in classify_physical_containment(run_root, effective):
20812 errors.append(f" {label}: {message}")
20813 return errors, warnings
20814
20815
20816def read_text_file_lines(path: str) -> list:
20817 """!
20818 @brief Read a text file into a list of lines.
20819 @param[in] path File to read.
20820 @return List of lines.
20821 """
20822 with open(path, "r", encoding="utf-8", errors="replace") as handle:
20823 return handle.readlines()
20824
20825
20827 """!
20828 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
20829 @param[in] args Command-line style argument list supplied to the function.
20830 """
20831 target_context = _resolve_submission_target(
20832 run_dir=getattr(args, "run_dir", None),
20833 study_dir=getattr(args, "study_dir", None),
20834 )
20835 try:
20836 require_storage_payload_local(target_context["root_dir"], "submission")
20837 except StorageError as exc:
20838 print(f"[FATAL] {exc}", file=sys.stderr)
20839 sys.exit(1)
20840 preflight_errors, preflight_warnings = preflight_staged_run_directories(
20841 target_context["root_dir"]
20842 )
20843 for warning in preflight_warnings:
20844 print(f"[WARN] {warning.strip()}", file=sys.stderr)
20845 if preflight_errors:
20846 print(
20847 "[FATAL] Submission preflight failed: the staged run has an unsafe run-directory "
20848 "configuration.",
20849 file=sys.stderr,
20850 )
20851 for violation in preflight_errors:
20852 print(violation, file=sys.stderr)
20853 print(
20854 " Re-stage the run so PICurv regenerates its canonical path flags.",
20855 file=sys.stderr,
20856 )
20857 sys.exit(1)
20858 if target_context["target_kind"] == "study":
20859 cold_cases = cold_study_members(target_context["root_dir"])
20860 if cold_cases:
20861 print(
20862 "[FATAL] Submission requires cold-storage study member(s): " + ", ".join(cold_cases),
20863 file=sys.stderr,
20864 )
20865 sys.exit(1)
20866 stage_order = ["solve", "post-process"]
20867 requested_stage = args.stage
20868 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
20869
20870 print(f"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
20871 print(f"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
20872 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
20873
20874 if target_context.get("launch_mode") == "local":
20875 submit_staged_local_run(args, target_context, selected_stages)
20876 return
20877
20878 stage_plans = []
20879 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
20880 solve_existing_job_id = str(solve_existing_meta.get("job_id", "")).strip()
20881
20882 for stage_name in selected_stages:
20883 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
20884 script_path = target_context["script_map"][stage_name]
20885 missing_stage_hint = _build_submit_missing_stage_hint(target_context, requested_stage, selected_stages)
20886 if not existing_meta:
20888 ERROR_CODE_CFG_MISSING_KEY,
20889 key=f"scheduler.{stage_name}.metadata",
20890 file_path=target_context["submission_path"],
20891 message=f"Submission metadata does not record stage '{stage_name}'.",
20892 hint=missing_stage_hint,
20893 )
20894 sys.exit(1)
20895
20896 if not os.path.isfile(script_path):
20898 ERROR_CODE_CFG_FILE_NOT_FOUND,
20899 key=f"scheduler.{stage_name}.script",
20900 file_path=script_path,
20901 message=f"Required {stage_name} sbatch artifact is missing.",
20902 hint=missing_stage_hint,
20903 )
20904 sys.exit(1)
20905
20906 if existing_meta.get("submitted") and not args.force:
20908 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20909 key=f"scheduler.{stage_name}.submitted",
20910 file_path=target_context["submission_path"],
20911 message=f"Stage '{stage_name}' is already recorded as submitted.",
20912 hint="Use --force to resubmit this stage intentionally.",
20913 )
20914 sys.exit(1)
20915
20916 dependency = None
20917 if stage_name == "post-process":
20918 if "solve" in selected_stages:
20919 dependency = "__NEW_SOLVE_JOB_ID__"
20920 else:
20921 if not (solve_existing_meta.get("submitted") and solve_existing_job_id):
20923 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20924 key="scheduler.post-process.dependency",
20925 file_path=target_context["submission_path"],
20926 message="Post-process submission requires a recorded solve job id when solve is not being submitted in the same command.",
20927 hint="Submit --stage solve or --stage all first, or use --force only after solve metadata exists.",
20928 )
20929 sys.exit(1)
20930 dependency = solve_existing_job_id
20931
20932 stage_plans.append(
20933 {
20934 "stage": stage_name,
20935 "script": script_path,
20936 "dependency": dependency,
20937 "existing_meta": existing_meta,
20938 }
20939 )
20940
20941 if args.dry_run:
20942 for plan in stage_plans:
20943 cmd = ["sbatch"]
20944 dependency = plan["dependency"]
20945 if dependency == "__NEW_SOLVE_JOB_ID__":
20946 cmd.append("--dependency=afterok:<new solve job id>")
20947 elif dependency:
20948 cmd.append(f"--dependency=afterok:{dependency}")
20949 cmd.append(plan["script"])
20950 print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
20951 print("[INFO] Dry-run only. No jobs were submitted.")
20952 return
20953
20954 latest_solve_job_id = None
20955 for plan in stage_plans:
20956 dependency = plan["dependency"]
20957 if dependency == "__NEW_SOLVE_JOB_ID__":
20958 dependency = latest_solve_job_id
20959
20960 submit_info = submit_sbatch(plan["script"], dependency=dependency)
20961 stage_meta = copy.deepcopy(plan["existing_meta"])
20962 stage_meta.update(submit_info)
20963 stage_meta["script"] = plan["script"]
20964 stage_meta["submitted"] = True
20965 if dependency:
20966 stage_meta["dependency"] = f"afterok:{dependency}"
20967 else:
20968 stage_meta.pop("dependency", None)
20969
20970 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
20971 print(f"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
20972
20973 if plan["stage"] == "solve":
20974 latest_solve_job_id = submit_info["job_id"]
20975
20976 _write_submission_target_metadata(target_context)
20977
20978
20979def submit_staged_local_run(args, target_context: dict, selected_stages: list):
20980 """!
20981 @brief Execute previously staged local run commands from scheduler/submission.json.
20982 @param[in] args Command-line style argument list supplied to the function.
20983 @param[in] target_context Resolved submission target context.
20984 @param[in] selected_stages Ordered stage names selected by the user.
20985 """
20986 if target_context["target_kind"] != "run":
20988 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20989 key="scheduler.launch_mode",
20990 file_path=target_context["submission_path"],
20991 message="Local staged execution is supported for run directories only.",
20992 hint="Use --run-dir for local staged execution.",
20993 )
20994 sys.exit(1)
20995
20996 stage_plans = []
20997 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
20998 solve_already_done = bool(solve_existing_meta.get("submitted") or solve_existing_meta.get("executed"))
20999
21000 for stage_name in selected_stages:
21001 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
21002 command = existing_meta.get("command")
21003 if not isinstance(command, list) or not command:
21004 if existing_meta:
21005 hint = "Re-stage the run with picurv run --no-submit before calling picurv submit."
21006 else:
21007 hint = _build_submit_missing_stage_hint(target_context, args.stage, selected_stages)
21009 ERROR_CODE_CFG_MISSING_KEY,
21010 key=f"scheduler.{stage_name}.command",
21011 file_path=target_context["submission_path"],
21012 message=f"Required local command metadata for stage '{stage_name}' is missing.",
21013 hint=hint,
21014 )
21015 sys.exit(1)
21016
21017 if existing_meta.get("submitted") and not args.force:
21019 ERROR_CODE_CFG_INCONSISTENT_COMBO,
21020 key=f"scheduler.{stage_name}.submitted",
21021 file_path=target_context["submission_path"],
21022 message=f"Stage '{stage_name}' is already recorded as submitted.",
21023 hint="Use --force to execute this stage again intentionally.",
21024 )
21025 sys.exit(1)
21026
21027 if stage_name == "post-process" and "solve" not in selected_stages and not args.force and not solve_already_done:
21029 ERROR_CODE_CFG_INCONSISTENT_COMBO,
21030 key="scheduler.post-process.dependency",
21031 file_path=target_context["submission_path"],
21032 message="Post-process local execution requires a recorded completed solve stage when solve is not being executed in the same command.",
21033 hint="Submit --stage solve or --stage all first, or use --force after confirming source data exists.",
21034 )
21035 sys.exit(1)
21036
21037 log_file = existing_meta.get("log_file")
21038 if not isinstance(log_file, str) or not log_file.strip():
21039 log_file = os.path.join("scheduler", f"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
21040
21041 stage_plans.append(
21042 {
21043 "stage": stage_name,
21044 "command": [str(token) for token in command],
21045 "log_file": log_file,
21046 "existing_meta": existing_meta,
21047 }
21048 )
21049
21050 if args.dry_run:
21051 for plan in stage_plans:
21052 print(f"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
21053 print(f"[DRY-RUN] Log file : {plan['log_file']}")
21054 print("[INFO] Dry-run only. No local commands were executed.")
21055 return
21056
21057 monitor_cfg = None
21058 monitor_path = os.path.join(target_context["root_dir"], "config", "monitor.yml")
21059 if os.path.isfile(monitor_path):
21060 monitor_cfg = read_yaml_file(monitor_path)
21061
21062 for plan in stage_plans:
21063 if plan["stage"] == "solve":
21064 try:
21065 with runtime_stage_lock(target_context["root_dir"], "solver"):
21066 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
21067 except StorageError as exc:
21068 print(f"[FATAL] {exc}", file=sys.stderr)
21069 sys.exit(1)
21070 else:
21071 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
21072 stage_meta = copy.deepcopy(plan["existing_meta"])
21073 stage_meta["command"] = plan["command"]
21074 stage_meta["command_string"] = format_command_for_display(plan["command"])
21075 stage_meta["log_file"] = plan["log_file"]
21076 stage_meta["submitted"] = True
21077 stage_meta["executed"] = True
21078 stage_meta["completed_at"] = datetime.now().isoformat()
21079 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
21080 print(f"[SUCCESS] Executed local {plan['stage']} stage.")
21081
21082 _write_submission_target_metadata(target_context)
21083
21084
21086 """!
21087 @brief Cancel Slurm-submitted jobs for an existing run directory.
21088 @param[in] args Command-line style argument list supplied to the function.
21089 """
21090 run_dir = os.path.abspath(args.run_dir)
21091 if not os.path.isdir(run_dir):
21093 ERROR_CODE_CFG_FILE_NOT_FOUND,
21094 key="run_dir",
21095 file_path=run_dir,
21096 message="Run directory not found.",
21097 )
21098 sys.exit(1)
21099
21100 submission_path = os.path.join(run_dir, "scheduler", "submission.json")
21101 submission_meta = _read_json_if_exists(submission_path)
21102 if not isinstance(submission_meta, dict):
21104 ERROR_CODE_CFG_FILE_NOT_FOUND,
21105 key="scheduler.submission",
21106 file_path=submission_path,
21107 message="Run directory does not contain scheduler submission metadata.",
21108 hint="Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
21109 )
21110 sys.exit(1)
21111
21112 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
21113 if launch_mode != "slurm":
21115 ERROR_CODE_CFG_INCONSISTENT_COMBO,
21116 key="scheduler.launch_mode",
21117 file_path=submission_path,
21118 message=f"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
21119 hint="picurv cancel currently supports Slurm-submitted runs only.",
21120 )
21121 sys.exit(1)
21122
21123 stage_order = ["solve", "post-process"]
21124 requested_stage = args.stage
21125 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
21126 recorded_stages = submission_meta.get("stages", {})
21127 if not isinstance(recorded_stages, dict):
21128 recorded_stages = {}
21129
21130 job_to_stages = {}
21131 skipped = []
21132 for stage_name in selected_stages:
21133 stage_meta = recorded_stages.get(stage_name)
21134 if not isinstance(stage_meta, dict):
21135 skipped.append((stage_name, "no stage metadata recorded"))
21136 continue
21137
21138 job_id = str(stage_meta.get("job_id", "")).strip()
21139 if not stage_meta.get("submitted"):
21140 skipped.append((stage_name, "job was generated but not submitted"))
21141 continue
21142 if not job_id:
21143 skipped.append((stage_name, "submitted stage is missing a recorded job id"))
21144 continue
21145
21146 job_to_stages.setdefault(job_id, []).append(stage_name)
21147
21148 if not job_to_stages:
21149 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
21150 print(f"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
21151 for stage_name, reason in skipped:
21152 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
21153 print("[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
21154 sys.exit(1)
21155
21156 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
21157 print(f"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
21158 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
21159
21160 if skipped:
21161 for stage_name, reason in skipped:
21162 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
21163
21164 graceful = bool(getattr(args, "graceful", False))
21165 failures = []
21166 for job_id, stage_names in job_to_stages.items():
21167 joined_stage_names = ", ".join(stage_names)
21168 use_graceful_signal = graceful and "solve" in stage_names
21169 scancel_cmd = ["scancel"]
21170 if use_graceful_signal:
21171 # A directly launched ``mpirun`` replaces the batch shell (via ``exec``)
21172 # and is therefore not necessarily a Slurm job step. ``--full`` also
21173 # signals the batch process and its children, so the MPI launcher can
21174 # forward SIGUSR1 to the solver ranks.
21175 scancel_cmd.extend(["--signal=USR1", "--full"])
21176 scancel_cmd.append(job_id)
21177
21178 if args.dry_run:
21179 print(f"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
21180 continue
21181
21182 result = subprocess.run(scancel_cmd, text=True, capture_output=True, check=False)
21183 stderr_text = (result.stderr or "").strip()
21184 stdout_text = (result.stdout or "").strip()
21185 if result.returncode == 0:
21186 if use_graceful_signal:
21187 print(
21188 f"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
21189 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
21190 )
21191 else:
21192 print(f"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
21193 continue
21194
21195 detail = stderr_text or stdout_text or "unknown scancel failure"
21196 failures.append((job_id, joined_stage_names, detail, result.returncode))
21197 print(
21198 f"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
21199 file=sys.stderr,
21200 )
21201
21202 if args.dry_run:
21203 print("[INFO] Dry-run only. No jobs were canceled.")
21204 return
21205
21206 if failures:
21207 sys.exit(1)
21208
21209
21211 """!
21212 @brief Infer the owned workspace role of one copied YAML file.
21213 @param[in] path YAML file copied from an example template.
21214 @return One of case, solver, monitor, post, cluster, study, or None.
21215 """
21216 try:
21217 payload = read_yaml_file(path)
21218 except (OSError, ValueError):
21219 return None
21220 keys = set(payload)
21221 if {"grid", "properties", "run_control"} <= keys:
21222 return "case"
21223 if "base_configs" in keys and ("study_type" in keys or "parameters" in keys or "parameter_sets" in keys):
21224 return "study"
21225 if "scheduler" in keys and "resources" in keys:
21226 return "cluster"
21227 if "source_data" in keys or "eulerian_pipeline" in keys or "lagrangian_pipeline" in keys:
21228 return "post"
21229 if "io" in keys and ("logging" in keys or "profiling" in keys or "diagnostics" in keys):
21230 return "monitor"
21231 if "momentum_solver" in keys or "poisson_solver" in keys or "operation_mode" in keys:
21232 return "solver"
21233 return None
21234
21235
21236def _rewrite_workspace_path_values(value, replacements: dict):
21237 """!
21238 @brief Rewrite copied template path scalars to workspace-root-relative homes.
21239 @param[in] value YAML subtree to rewrite.
21240 @param[in] replacements Old relative/basename paths mapped to new workspace paths.
21241 @return Rewritten YAML subtree.
21242 """
21243 if isinstance(value, dict):
21244 return {key: _rewrite_workspace_path_values(item, replacements) for key, item in value.items()}
21245 if isinstance(value, list):
21246 return [_rewrite_workspace_path_values(item, replacements) for item in value]
21247 if not isinstance(value, str):
21248 return value
21249 normalized = value.replace("\\", "/").lstrip("./")
21250 return replacements.get(normalized, replacements.get(os.path.basename(normalized), value))
21251
21252
21253def _choose_primary_workspace_role(candidates: list, role: str, template_name: str):
21254 """!
21255 @brief Select the canonical role file from a template that may carry variants.
21256 @param[in] candidates Candidate absolute YAML paths.
21257 @param[in] role Config role being selected.
21258 @param[in] template_name Source example directory name.
21259 @return Selected absolute path or None.
21260 """
21261 if not candidates:
21262 return None
21263 preferred = {
21264 "case": ("case.yml", f"{template_name}.yml", "master_case.yml"),
21265 "solver": ("solver.yml", "Imp-MG-Standard.yml", "master_solver.yml"),
21266 "monitor": ("monitor.yml", "Standard_Output.yml", "master_monitor.yml"),
21267 "post": ("post.yml", "standard_analysis.yml", "master_postprocessor.yml"),
21268 "cluster": ("cluster.yml", "slurm_cluster.yml", "master_cluster.yml"),
21269 }.get(role, ())
21270 by_name = {os.path.basename(path): path for path in candidates}
21271 for name in preferred:
21272 if name in by_name:
21273 return by_name[name]
21274 return sorted(candidates)[0]
21275
21276
21277def organize_initialized_workspace(workspace_root: str, template_name: str,
21278 source_template_root: str = None) -> dict:
21279 """!
21280 @brief Convert a copied example into the canonical editable workspace layout.
21281 @param[in] workspace_root Newly copied workspace root.
21282 @param[in] template_name Example template identity.
21283 @param[in] source_template_root Optional original template root used to vendor references.
21284 @return Mapping of canonical config roles and relocated imported inputs.
21285 """
21286 workspace_root = os.path.abspath(workspace_root)
21287 ensure_workspace_layout(workspace_root)
21288 yaml_files = [
21289 str(path) for path in Path(workspace_root).rglob("*.yml")
21290 if path.name not in {RUNTIME_EXECUTION_EXAMPLE_FILENAME, RUNTIME_EXECUTION_CONFIG_FILENAME}
21291 and "runs" not in path.parts and "studies" not in path.parts
21292 ]
21293 yaml_files.extend(
21294 str(path) for path in Path(workspace_root).rglob("*.yaml")
21295 if path.name not in {RUNTIME_EXECUTION_EXAMPLE_FILENAME, RUNTIME_EXECUTION_CONFIG_FILENAME}
21296 and "runs" not in path.parts and "studies" not in path.parts
21297 )
21298 vendored_replacements = {}
21299 if source_template_root:
21300 source_template_root = os.path.abspath(source_template_root)
21301
21302 def vendor_references(value, source_yaml: str, dotted: str = ""):
21303 """!
21304 @brief Vendor referenced generator inputs into canonical workspace homes.
21305 @param[in] value YAML subtree to inspect.
21306 @param[in] source_yaml Original template YAML path.
21307 @param[in] dotted Current dotted key path.
21308 @return None.
21309 """
21310 if isinstance(value, dict):
21311 for key, child in value.items():
21312 vendor_references(child, source_yaml, f"{dotted}.{key}" if dotted else str(key))
21313 return
21314 if isinstance(value, list):
21315 for index, child in enumerate(value):
21316 vendor_references(child, source_yaml, f"{dotted}[{index}]")
21317 return
21318 key = dotted.rsplit(".", 1)[-1]
21319 if key not in _VENDORABLE_CONFIG_REFERENCE_KEYS or not isinstance(value, str) or not value.strip():
21320 return
21321 origin = os.path.abspath(os.path.join(os.path.dirname(source_yaml), value))
21322 if not os.path.isfile(origin):
21323 return
21324 if key == "script":
21325 home = "config/generators"
21326 elif "grid" in dotted:
21327 home = "config/grids"
21328 elif "initial_condition" in dotted:
21329 home = "config/initial_conditions"
21330 elif "inlet" in dotted or "boundary_conditions" in dotted:
21331 home = "config/inlet_profiles"
21332 else:
21333 home = "config"
21334 destination = os.path.join(workspace_root, *home.split("/"), os.path.basename(origin))
21335 os.makedirs(os.path.dirname(destination), exist_ok=True)
21336 if not os.path.exists(destination):
21337 shutil.copy2(origin, destination)
21338 relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
21339 vendored_replacements[value.replace("\\", "/").lstrip("./")] = relative
21340 vendored_replacements[os.path.basename(value)] = relative
21341
21342 for copied_yaml in sorted(set(yaml_files)):
21343 relative = os.path.relpath(copied_yaml, workspace_root)
21344 source_yaml = os.path.join(source_template_root, relative)
21345 if not os.path.isfile(source_yaml):
21346 continue
21347 try:
21348 vendor_references(read_yaml_file(copied_yaml), source_yaml)
21349 except (OSError, ValueError):
21350 continue
21351 roles = {}
21352 for path in sorted(set(yaml_files)):
21353 role = _workspace_yaml_role(path)
21354 if role:
21355 roles.setdefault(role, []).append(path)
21356
21357 selected = {
21358 role: _choose_primary_workspace_role(paths, role, template_name)
21359 for role, paths in roles.items() if role != "study"
21360 }
21361 destinations = {}
21362 occupied = set()
21363 for role, paths in roles.items():
21364 for source in paths:
21365 if role == "study":
21366 relative = os.path.join("config", "studies", os.path.basename(source))
21367 elif source == selected.get(role):
21368 relative = os.path.join("config", f"{role}.yml")
21369 else:
21370 relative = os.path.join("config", os.path.basename(source))
21371 destination = os.path.join(workspace_root, relative)
21372 if os.path.abspath(source) == os.path.abspath(destination):
21373 destinations[source] = relative.replace(os.sep, "/")
21374 occupied.add(os.path.abspath(destination))
21375 continue
21376 if os.path.abspath(destination) in occupied or os.path.exists(destination):
21377 original_relative = os.path.relpath(source, workspace_root)
21378 relative = os.path.join("config", "variants", original_relative)
21379 destination = os.path.join(workspace_root, relative)
21380 counter = 2
21381 while os.path.abspath(destination) in occupied or os.path.exists(destination):
21382 stem, suffix = os.path.splitext(relative)
21383 destination = os.path.join(workspace_root, f"{stem}-{counter}{suffix}")
21384 counter += 1
21385 os.makedirs(os.path.dirname(destination), exist_ok=True)
21386 shutil.move(source, destination)
21387 destinations[source] = relative.replace(os.sep, "/")
21388 occupied.add(os.path.abspath(destination))
21389
21390 input_extensions = {
21391 ".picgrid": "inputs/grids",
21392 ".vts": "inputs/grids",
21393 ".picslice": "inputs/inlet_profiles",
21394 ".dat": "inputs/initial_conditions",
21395 }
21396 input_moves = {}
21397 for path in sorted(Path(workspace_root).rglob("*")):
21398 if not path.is_file() or path.suffix.lower() not in input_extensions:
21399 continue
21400 if any(part in _WORKSPACE_MANAGED_PATHS for part in path.relative_to(workspace_root).parts):
21401 continue
21402 destination_dir = os.path.join(workspace_root, *input_extensions[path.suffix.lower()].split("/"))
21403 destination = os.path.join(destination_dir, path.name)
21404 if os.path.exists(destination):
21405 continue
21406 old_relative = path.relative_to(workspace_root).as_posix()
21407 shutil.move(str(path), destination)
21408 new_relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
21409 input_moves[old_relative] = new_relative
21410 input_moves[path.name] = new_relative
21411
21412 config_replacements = {}
21413 basename_counts = {}
21414 for source in destinations:
21415 basename = os.path.basename(source)
21416 basename_counts[basename] = basename_counts.get(basename, 0) + 1
21417 for source, relative in destinations.items():
21418 old_relative = os.path.relpath(source, workspace_root).replace(os.sep, "/")
21419 config_replacements[old_relative] = relative
21420 if basename_counts[os.path.basename(source)] == 1:
21421 config_replacements[os.path.basename(source)] = relative
21422 replacements = {**config_replacements, **input_moves, **vendored_replacements}
21423 for path in sorted(Path(workspace_root, "config").rglob("*.yml")):
21424 payload = read_yaml_file(str(path))
21425 local_replacements = dict(replacements)
21426 original_source = next(
21427 (
21428 source for source, relative in destinations.items()
21429 if os.path.abspath(os.path.join(workspace_root, relative)) == os.path.abspath(path)
21430 ),
21431 None,
21432 )
21433 if original_source:
21434 original_parent = os.path.dirname(original_source)
21435 for candidate_source, candidate_relative in destinations.items():
21436 relative_reference = os.path.relpath(candidate_source, original_parent).replace(os.sep, "/")
21437 local_replacements[relative_reference] = candidate_relative
21438 if os.path.dirname(candidate_source) == original_parent:
21439 local_replacements[os.path.basename(candidate_source)] = candidate_relative
21440 rewritten = _rewrite_workspace_path_values(payload, local_replacements)
21441 role = _workspace_yaml_role(str(path))
21442 if role == "monitor" and isinstance(rewritten.get("io"), dict):
21443 rewritten["io"].pop("directories", None)
21444 if role == "case" and path == Path(workspace_root, "config", "case.yml"):
21445 rewritten.setdefault("title", template_name)
21446 if role == "post":
21447 if isinstance(rewritten.get("source_data"), dict):
21448 rewritten["source_data"].pop("directory", None)
21449 if isinstance(rewritten.get("io"), dict):
21450 rewritten["io"].pop("output_directory", None)
21451 if role == "study" and isinstance(rewritten.get("base_configs"), dict):
21452 for role in ("case", "solver", "monitor", "post"):
21453 canonical = os.path.join("config", f"{role}.yml").replace(os.sep, "/")
21454 if os.path.isfile(os.path.join(workspace_root, canonical)):
21455 rewritten["base_configs"][role] = canonical
21456 write_yaml_file(str(path), rewritten)
21457
21458 return {
21459 "workspace_config": initialize_workspace_root(workspace_root, template_name),
21460 "canonical_roles": {
21461 role: os.path.join(workspace_root, "config", f"{role}.yml")
21462 for role in ("case", "solver", "monitor", "post", "cluster")
21463 if os.path.isfile(os.path.join(workspace_root, "config", f"{role}.yml"))
21464 },
21465 "input_moves": input_moves,
21466 }
21467
21468
21469WORKSPACE_INPUT_DIRECTORIES = {
21470 "grid": "inputs/grids",
21471 "initial-condition": "inputs/initial_conditions",
21472 "inlet-profile": "inputs/inlet_profiles",
21473 "reference-field": "inputs/reference_fields",
21474}
21475
21476
21477def import_workspace_input(workspace_root: str, kind: str, source: str,
21478 name: str = None, mode: str = "copy") -> dict:
21479 """!
21480 @brief Explicitly import or register one workspace input.
21481 @param[in] workspace_root Initialized workspace root.
21482 @param[in] kind Semantic input kind.
21483 @param[in] source Existing source file path.
21484 @param[in] name Optional destination basename.
21485 @param[in] mode Copy, reflink, hardlink, or external-reference mode.
21486 @return Catalog entry describing the durable input identity.
21487 """
21488 workspace_root = os.path.abspath(workspace_root)
21489 load_workspace_config(workspace_root)
21490 ensure_workspace_layout(workspace_root)
21491 if kind not in WORKSPACE_INPUT_DIRECTORIES:
21492 raise ValueError(f"Unsupported input kind: {kind}")
21493 source = os.path.abspath(os.path.expanduser(source))
21494 if not os.path.isfile(source):
21495 raise ValueError(f"Input source is not a file: {source}")
21496 basename = name or os.path.basename(source)
21497 if basename != os.path.basename(basename) or basename in _PLAIN_FILENAME_SENTINELS:
21498 raise ValueError("--name must be a plain filename without directory traversal.")
21499 target_dir = os.path.join(workspace_root, *WORKSPACE_INPUT_DIRECTORIES[kind].split("/"))
21500 os.makedirs(target_dir, exist_ok=True)
21501 if mode == "reference":
21502 destination = os.path.join(target_dir, basename + ".reference.yml")
21503 write_yaml_file(destination, {
21504 "schema_version": 1,
21505 "picurv_external_reference": source,
21506 "sha256_at_registration": _asset_file_sha256(source),
21507 "bytes_at_registration": os.path.getsize(source),
21508 })
21509 else:
21510 destination = os.path.join(target_dir, basename)
21511 if os.path.exists(destination):
21512 raise ValueError(f"Workspace input already exists: {destination}")
21513 temporary = f"{destination}.tmp.{os.getpid()}"
21514 try:
21515 if mode == "copy":
21516 shutil.copy2(source, temporary)
21517 elif mode == "hardlink":
21518 os.link(source, temporary)
21519 elif mode == "reflink":
21520 cp = shutil.which("cp")
21521 if not cp:
21522 raise ValueError("reflink mode requires the 'cp' command.")
21523 result = subprocess.run(
21524 [cp, "--reflink=always", "--preserve=mode,timestamps", source, temporary],
21525 text=True, capture_output=True, check=False,
21526 )
21527 if result.returncode != 0:
21528 raise ValueError((result.stderr or "reflink copy failed").strip())
21529 else:
21530 raise ValueError(f"Unsupported import mode: {mode}")
21531 os.replace(temporary, destination)
21532 finally:
21533 if os.path.lexists(temporary):
21534 os.remove(temporary)
21535 relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
21536 entry_id = _stable_mapping_sha256({"kind": kind, "path": relative, "source": source})[:16]
21537 catalog_path = os.path.join(workspace_root, "inputs", "catalog.yml")
21538 catalog = read_yaml_file(catalog_path) if os.path.isfile(catalog_path) else {
21539 "schema_version": 1, "inputs": {}
21540 }
21541 catalog.setdefault("inputs", {})[entry_id] = {
21542 "kind": kind,
21543 "path": relative,
21544 "mode": mode,
21545 "sha256": _asset_file_sha256(source),
21546 "bytes": os.path.getsize(source),
21547 "source": source if mode == "reference" else None,
21548 "registered_at": datetime.now().astimezone().isoformat(),
21549 }
21550 write_yaml_file(catalog_path, catalog)
21551 return {"id": entry_id, **catalog["inputs"][entry_id]}
21552
21553
21555 """!
21556 @brief Handle explicit workspace input management.
21557 @param[in] args Parsed inputs command arguments.
21558 @return None.
21559 """
21560 workspace_root = os.path.abspath(args.workspace) if args.workspace else find_workspace_root(os.getcwd())
21561 if not workspace_root:
21562 raise ValueError("No initialized workspace found; run picurv init first or pass --workspace.")
21563 if args.inputs_action != "import":
21564 raise ValueError(f"Unsupported inputs action: {args.inputs_action}")
21565 entry = import_workspace_input(
21566 workspace_root, args.kind, args.source, name=args.name, mode=args.mode
21567 )
21568 print(f"[SUCCESS] Registered input {entry['id']}: {entry['path']}")
21569 if entry["mode"] == "reference":
21570 print("[WARNING] This is an external reference. Storage will record it but will not copy or prune its target.")
21571
21572
21574 """!
21575 @brief Report, and for the `status` action validate, the shared build identity.
21576
21577 @details Bare `picurv version` reports and always succeeds; it is an informational
21578 surface that scripts and documentation already depend on. `picurv version
21579 status` additionally exits non-zero when the conductor, the executables,
21580 and the workspace requirement do not agree, so a job script can refuse to
21581 launch a run whose provenance would be incoherent.
21582 @param[in] args Parsed version command arguments.
21583 @return None.
21584 """
21585 workspace_root = find_workspace_root(os.getcwd())
21586 payload = dict(PICURV_BUILD)
21587 payload["source_root"] = PACKAGE_PROJECT_ROOT
21588 payload["workspace"] = workspace_root
21589 payload["workspace_requirement"] = None
21590 if workspace_root:
21591 software = load_workspace_config(workspace_root).get("software") or {}
21592 payload["workspace_requirement"] = software.get("picurv") if isinstance(software, dict) else None
21593 payload["binaries"] = runtime_build_identities()
21594 validating = getattr(args, "version_action", None) == "status"
21595 problems = build_identity_problems(payload["binaries"], payload["workspace_requirement"])
21596 payload["coherent"] = not problems
21597 payload["problems"] = problems
21598 if getattr(args, "output_format", "text") == "json":
21599 print(json.dumps(payload, indent=2, sort_keys=True))
21600 if validating and problems:
21601 sys.exit(1)
21602 return
21603 print(f"PICurv release : {payload['release_version']}")
21604 print(f"Build identity : {payload['build_id']}")
21605 print(f"Git commit : {payload.get('git_commit') or 'unavailable'}")
21606 print(f"Dirty tree : {payload.get('dirty') if payload.get('dirty') is not None else 'unknown'}")
21607 if workspace_root:
21608 print(f"Workspace : {workspace_root}")
21609 print(f"Requirement : {payload['workspace_requirement'] or 'latest active version'}")
21610 print("\nNative executables")
21611 for name, identity in sorted(payload["binaries"].items()):
21612 if not identity.get("available"):
21613 print(f" {name:<14}: unavailable ({identity.get('reason', 'unknown')})")
21614 continue
21615 agreement = "matches source" if identity["matches_source"] else "STALE - rebuild"
21616 print(f" {name:<14}: {identity['build_id']} ({agreement})")
21617 if not validating:
21618 warn_on_stale_runtime_binaries(payload["binaries"])
21619 return
21620 if not problems:
21621 print("\nBuild identity is coherent.")
21622 return
21623 print("\nBuild identity is NOT coherent:", file=sys.stderr)
21624 for problem in problems:
21625 print(f" - {problem}", file=sys.stderr)
21626 sys.exit(1)
21627
21628
21629def _require_clean_source_checkout(action: str) -> None:
21630 """!
21631 @brief Refuse version-changing Git operations in a dirty source checkout.
21632 @param[in] action User-facing action name.
21633 @return None.
21634 """
21635 result = subprocess.run(
21636 ["git", "status", "--porcelain"], cwd=PACKAGE_PROJECT_ROOT,
21637 text=True, capture_output=True, check=False,
21638 )
21639 if result.returncode != 0:
21640 raise ValueError(f"{action}: cannot inspect the PICurv Git checkout.")
21641 if result.stdout.strip():
21642 raise ValueError(
21643 f"{action}: the PICurv source checkout has uncommitted changes. "
21644 "Commit or stash them before changing versions."
21645 )
21646
21647
21648def _git_source_command(arguments: list) -> subprocess.CompletedProcess:
21649 """!
21650 @brief Run a checked Git command against the active PICurv source checkout.
21651 @param[in] arguments Git arguments excluding the executable.
21652 @return Completed successful Git process.
21653 """
21654 result = subprocess.run(
21655 ["git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
21656 text=True, capture_output=True, check=False,
21657 )
21658 if result.returncode != 0:
21659 raise ValueError((result.stderr or result.stdout or "Git command failed.").strip())
21660 return result
21661
21662
21664 """!
21665 @brief Fetch source history without silently changing the active code.
21666 @param[in] args Parsed source command arguments.
21667 @return None.
21668 """
21669 if args.source_action != "update":
21670 raise ValueError(f"Unsupported source action: {args.source_action}")
21671 _git_source_command(["fetch", "--tags", "--prune", args.remote])
21672 print(f"[SUCCESS] Fetched branches and tags from {args.remote}; active checkout was not changed.")
21673
21674
21675def _workspace_requested_version(workspace_root: str):
21676 """!
21677 @brief Resolve an exact version requested by an initialized workspace.
21678 @param[in] workspace_root Initialized workspace root.
21679 @return Exact version or tag text.
21680 """
21681 software = load_workspace_config(workspace_root).get("software") or {}
21682 requirement = software.get("picurv") if isinstance(software, dict) else None
21683 if not requirement:
21684 raise ValueError(
21685 f"{workspace_root}/{WORKSPACE_CONFIG_FILENAME} has no software.picurv pin; "
21686 "name a version explicitly."
21687 )
21688 if any(token in str(requirement) for token in "<>=!~,*"):
21689 raise ValueError(
21690 "Automatic activation needs an exact release/tag, not a version range. "
21691 f"Pass the desired version explicitly (workspace requires {requirement!r})."
21692 )
21693 return str(requirement)
21694
21695
21697 """!
21698 @brief List or activate a release using the existing source/build owners.
21699 @param[in] args Parsed versions command arguments.
21700 @return None.
21701 """
21702 action = args.versions_action
21703 if action == "list":
21704 result = _git_source_command(["tag", "--list", "--sort=-version:refname"])
21705 print(f"Active: {PICURV_BUILD['build_id']}")
21706 tags = [line for line in result.stdout.splitlines() if line.strip()]
21707 if tags:
21708 print("Installed/available tags:")
21709 for tag in tags:
21710 print(f" {tag}")
21711 else:
21712 print("No version tags are present in this checkout.")
21713 return
21714 version = getattr(args, "version", None)
21715 if action == "activate" and not version:
21716 workspace_root = os.path.abspath(args.workspace) if args.workspace else find_workspace_root(os.getcwd())
21717 if not workspace_root:
21718 raise ValueError("No workspace found and no version was named.")
21719 version = _workspace_requested_version(workspace_root)
21720 if action not in _VERSION_BUILD_ACTIONS:
21721 raise ValueError(f"Unsupported versions action: {action}")
21722 _require_clean_source_checkout(f"versions {action}")
21723 # There is one installation, and this rewrites it in place. Anything resolving
21724 # executables from it - other workspaces, and any running job that did not pin its
21725 # binaries - is changed by this too, so say what is about to move before it moves.
21726 print(
21727 f"[WARNING] {PACKAGE_PROJECT_ROOT} is a single shared installation. Building "
21728 f"{version!r} here re-points every workspace that resolves executables from it, "
21729 "including any job already running against them.",
21730 file=sys.stderr,
21731 )
21732 print(
21733 " Case-local executables pinned with 'picurv init --pin-binaries' are "
21734 "unaffected.",
21735 file=sys.stderr,
21736 )
21737 _git_source_command(["fetch", "--tags", "origin"])
21738 _git_source_command(["checkout", "--detach", str(version)])
21739 result = subprocess.run(["make", "all"], cwd=PACKAGE_PROJECT_ROOT, check=False)
21740 if result.returncode != 0:
21741 raise ValueError(f"Build failed after activating {version!r}.")
21742 print(f"[SUCCESS] Activated and built PICurv {version}.")
21743
21744
21745def init_case(args):
21746 """!
21747 @brief Implements the 'init' command.
21748 @details Creates a new case study directory by copying a template.
21749 Runtime binaries are resolved from the project bin/ directory
21750 via PATH; pass --pin-binaries to pin specific versions locally.
21751 @param[in] args The command-line arguments parsed by argparse.
21752 """
21753 context = resolve_case_origin_context(source_root_override=getattr(args, "source_root", None))
21754 try:
21755 source_project_root = require_project_root(context["source_project_root"], "init")
21756 template_path = resolve_template_directory(source_project_root, args.template_name)
21757 except ValueError as exc:
21758 print(f"[FATAL] {exc}", file=sys.stderr)
21759 sys.exit(1)
21760
21761 # The destination path is relative to the current working directory.
21762 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name if args.dest_name else args.template_name))
21763
21764 if os.path.exists(dest_path):
21765 print(f"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
21766 sys.exit(1)
21767
21768 print(f"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
21769
21770 shutil.copytree(template_path, dest_path)
21771 print(f"[SUCCESS] Copied template files to: {dest_path}")
21772
21773 copied_runtime_example = os.path.join(dest_path, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
21774 if os.path.isfile(copied_runtime_example):
21775 os.remove(copied_runtime_example)
21776
21777 try:
21778 workspace_layout = organize_initialized_workspace(
21779 dest_path, args.template_name, source_template_root=template_path
21780 )
21781 print(f"[INFO] Wrote workspace identity: {os.path.relpath(workspace_layout['workspace_config'])}")
21782 if workspace_layout["canonical_roles"]:
21783 print("[INFO] Canonical editable configurations:")
21784 for role, path in sorted(workspace_layout["canonical_roles"].items()):
21785 print(f" - {role}: {os.path.relpath(path)}")
21786 except Exception as exc:
21787 print(f"[FATAL] Failed to create canonical workspace layout: {exc}", file=sys.stderr)
21788 shutil.rmtree(dest_path, ignore_errors=True)
21789 sys.exit(1)
21790
21791 try:
21792 runtime_result = ensure_case_runtime_execution_config(dest_path, source_project_root, overwrite=True)
21793 print(f"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
21794 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
21795 print(" Seeded from repo-local '.picurv-execution.yml'.")
21796 print(" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
21797 except Exception as e:
21798 print(f"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
21799
21800 try:
21801 metadata_path, _ = write_case_origin_metadata(
21802 dest_path,
21803 source_project_root,
21804 template_name=args.template_name,
21805 template_managed_files=list_template_relative_files(
21806 template_path,
21807 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
21808 ),
21809 )
21810 print(f"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
21811 except Exception as e:
21812 print(f"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
21813
21814 cluster_profile_candidates = sorted(
21815 {
21816 os.path.basename(path)
21817 for pattern in ("*cluster*.yml", "*cluster*.yaml")
21818 for search_root in (dest_path, os.path.join(dest_path, "config"))
21819 for path in glob.glob(os.path.join(search_root, pattern))
21820 }
21821 )
21822 if cluster_profile_candidates:
21823 print("[INFO] Cluster profile sample(s) copied with this case:")
21824 for profile_name in cluster_profile_candidates:
21825 print(f" - {profile_name}")
21826 print(" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
21827
21828 if getattr(args, "pin_binaries", False):
21829 print("[INFO] Pinning runtime binaries into case directory...")
21830 try:
21831 copied_binaries = sync_case_binaries(dest_path, source_project_root)
21832 for dest_file_path in copied_binaries:
21833 print(f" - Pinned '{os.path.basename(dest_file_path)}'")
21834 print("[SUCCESS] Case directory is ready with pinned binaries.")
21835 print(" These local copies will be used instead of bin/ originals.")
21836 except ValueError as exc:
21837 print(f"[WARNING] {exc}", file=sys.stderr)
21838 print(" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
21839 else:
21840 print("[SUCCESS] Case directory is ready.")
21841 print(" Runtime binaries (simulator, postprocessor) are resolved from the active PICurv installation.")
21842 print(" Pin software.picurv in .picurv-workspace.yml only when this workspace needs a release constraint.")
21843 print(" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
21844
21845
21847 """!
21848 @brief Refresh template-managed config/docs files in a case directory.
21849 @param[in] args Command-line style argument list supplied to the function.
21850 """
21851 try:
21853 case_dir_hint=getattr(args, "case_dir", None),
21854 source_root_override=getattr(args, "source_root", None),
21855 template_name_override=getattr(args, "template_name", None),
21856 )
21857 source_project_root = require_project_root(context["source_project_root"], "sync-config")
21858 case_dir = require_existing_case_dir(context["case_dir"], "sync-config", source_project_root)
21859 template_name = context.get("template_name")
21860 template_dir = resolve_template_directory(source_project_root, template_name)
21861 existing_managed = context.get("metadata", {}).get("template_managed_files")
21862 if not isinstance(existing_managed, list):
21863 existing_managed = None
21864 summary = sync_case_template_files(
21865 case_dir,
21866 template_dir,
21867 overwrite=getattr(args, "overwrite", False),
21868 prune=getattr(args, "prune", False),
21869 managed_rel_paths=existing_managed,
21870 )
21871 metadata_path, _ = write_case_origin_metadata(
21872 case_dir,
21873 source_project_root,
21874 template_name=template_name,
21875 existing=context.get("metadata"),
21876 template_managed_files=summary["template_managed_files"],
21877 )
21878 runtime_result = ensure_case_runtime_execution_config(case_dir, source_project_root, overwrite=False)
21879 except ValueError as exc:
21880 print(f"[FATAL] {exc}", file=sys.stderr)
21881 sys.exit(1)
21882
21883 print(f"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
21884 print(f"[INFO] Copied new files : {len(summary['copied'])}")
21885 print(f"[INFO] Overwritten files : {len(summary['overwritten'])}")
21886 print(f"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
21887 print(f"[INFO] Already unchanged : {len(summary['unchanged'])}")
21888 print(f"[INFO] Pruned stale files : {len(summary['pruned'])}")
21889 if runtime_result["created"]:
21890 print(f"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
21891 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
21892 print("[INFO] Seed source : repo-local .picurv-execution.yml")
21893 if summary.get("prune_requested_without_tracking"):
21894 print("[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
21895 print(f"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
21896
21897
21899 """!
21900 @brief Refresh source branches in the repository resolved from a case directory.
21901 @param[in] args Command-line style argument list supplied to the function.
21902 """
21903 try:
21905 case_dir_hint=getattr(args, "case_dir", None),
21906 source_root_override=getattr(args, "source_root", None),
21907 )
21908 source_project_root = require_project_root(context["source_project_root"], "pull-source")
21909 except ValueError as exc:
21910 print(f"[FATAL] {exc}", file=sys.stderr)
21911 sys.exit(1)
21912
21913 rebase = not getattr(args, "no_rebase", False)
21914 remote = getattr(args, "remote", None)
21915 branch = getattr(args, "branch", None)
21916 current_branch_only = (
21917 getattr(args, "current_branch_only", False)
21918 or remote is not None
21919 or branch is not None
21920 )
21921
21922 if not current_branch_only:
21923 pull_all_source_branches(source_project_root, "pull-source.log", rebase=rebase)
21924 return
21925
21926 command = ["git", "pull"]
21927 if rebase:
21928 command.append("--rebase")
21929 if remote:
21930 command.append(remote)
21931 if branch:
21932 command.append(branch)
21933 elif branch:
21934 command.extend(["origin", branch])
21935
21936 execute_command(command, source_project_root, "pull-source.log", {})
21937
21939 """!
21940 @brief Implements the 'build' command.
21941 @details Executes the top-level Makefile directly, passing through any
21942 additional arguments to `make`. This allows for building,
21943 cleaning, and other Makefile targets via the orchestrator
21944 without maintaining a separate build wrapper script.
21945 @param[in] args The command-line arguments parsed by argparse.
21946 """
21947
21948 print("\n" + "="*27 + " BUILD STAGE " + "="*27)
21949 try:
21951 case_dir_hint=getattr(args, "case_dir", None),
21952 source_root_override=getattr(args, "source_root", None),
21953 )
21954 source_project_root = require_project_root(context["source_project_root"], "build")
21955 except ValueError as exc:
21956 print(f"[FATAL] {exc}", file=sys.stderr)
21957 sys.exit(1)
21958
21959 makefile_path = os.path.join(source_project_root, "Makefile")
21960
21961 if not os.path.isfile(makefile_path):
21962 print(f"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
21963 print(" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
21964 sys.exit(1)
21965
21966 make_args = list(args.make_args or [])
21967 if make_args_include_explicit_goal(make_args):
21968 command = ["make"] + make_args
21969 else:
21970 command = ["make", "all"] + make_args
21971 print("[INFO] No explicit make target supplied; defaulting to 'all'.")
21972 print(" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
21973 # For the build process, we don't have a monitor.yml, so we pass an empty
21974 # dict to execute_command. The command should be run in the project root.
21975 execute_command(command, source_project_root, "build.log", {})
21976
21977
21978
21979
21980# ==============================================================================
Raised when an external command exits unsuccessfully.
Definition core.py:13292
__init__(self, list command, int returncode, str details=None)
Initialize a command execution error.
Definition core.py:13297
Raised when plot.gen reports a missing optional dependency.
Definition core.py:13313
Module-like proxy that preserves picurv.np without eager import.
Definition core.py:78
__getattr__(self, name)
Resolve a NumPy attribute on first use.
Definition core.py:83
summarize_workflow(args)
Build and render a read-only health summary for a run step.
Definition core.py:20326
str get_monitor_output_directory(dict monitor_cfg, str default="output")
Resolve the solver output root from monitor.yml, preserving the default layout.
Definition core.py:2789
dict _summarize_turbulence(dict turbulence_cfg)
Build compact turbulence and wall-model selections.
Definition core.py:17641
set resolve_post_stage_selection(only)
Resolve the --only selector into the set of post stages to execute.
Definition core.py:3224
dict translate_programmatic_grid_settings(dict grid_settings)
Return programmatic-grid settings translated to the C node-count contract.
Definition core.py:10506
tuple validate_run_directory_containment(dict monitor_cfg, str monitor_path)
Classify legacy directory values as defense-in-depth during validation.
Definition core.py:7587
find_runtime_execution_config_file(*anchors)
Find the nearest optional execution config from runtime/case anchors.
Definition core.py:1988
int normalize_particle_init_mode(str value)
Maps canonical particle init mode names to C enum/int codes (-pinit).
Definition core.py:11748
_write_submission_target_metadata(dict target_context)
Write updated submission metadata back to disk.
Definition core.py:20681
status_source_command(args)
Report source/case drift for an initialized case directory.
Definition core.py:2598
_render_monitor_summary_text(dict summary)
Render the monitor summary as a glanceable observability dashboard.
Definition core.py:19348
validate_workflow(args)
Implements picurv validate without launching solver/post workflows.
Definition core.py:15034
dict _normalize_square_duct_poiseuille_params(params, str field_name)
Validate square-duct Poiseuille generator parameters.
Definition core.py:5431
str _capture_command_stdout(list command, str run_dir)
Run a command, require success, and return stripped stdout text.
Definition core.py:13352
list expand_parameter_matrix(dict parameters)
Expand study parameter lists into cartesian-product combinations.
Definition core.py:9047
_render_case_summary_text(dict summary)
Render the case summary as a glanceable simulation dashboard.
Definition core.py:19235
_parse_float_loose(value)
Best-effort float parsing for summary extraction.
Definition core.py:17494
render_run_summary(dict payload, str output_format="text")
Render a run-step summary in human or JSON form.
Definition core.py:18878
control_value(value, str context)
Guard a value that is written verbatim into the generated control file.
Definition core.py:12175
int normalize_les_model(value)
Maps LES model selectors to C enum/int codes (-les).
Definition core.py:11793
list read_text_file_lines(str path)
Read a text file into a list of lines.
Definition core.py:20816
bool needs_restart_source(dict case_cfg, dict solver_cfg)
Return True when the solver requires restart data from disk.
Definition core.py:4458
str resolve_target_grid_for_generated_profile(dict case_cfg, str case_path, str run_dir)
Resolve an optional target canonical PICGRID for generated profile sampling.
Definition core.py:5797
str ensure_post_lock_wrapper(str run_dir)
Ensure the lock wrapper exists for a run directory and return its path.
Definition core.py:4281
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.
Definition core.py:4544
validate_study_config(dict study_cfg, str study_path, bool skip_base_file_check=False)
Validate sweep/study specification from study.yml.
Definition core.py:8904
"tuple[dict, str]" compute_post_recipe_fingerprint(dict recipe_cfg)
Return normalized recipe signature plus SHA-256 fingerprint.
Definition core.py:3786
list_template_relative_files(str template_dir, excluded_rel_paths=None)
List all files in a template directory as case-relative paths.
Definition core.py:2325
int normalize_les_clip_mode(value)
Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
Definition core.py:11917
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.
Definition core.py:2020
list _representative_indices(int count, int maximum=6)
Select evenly distributed indices while always retaining both endpoints.
Definition core.py:20011
dict _build_run_overview(dict context)
Build timestep-independent run metadata for summarize.
Definition core.py:17618
_iter_parent_dirs(str start_path)
Yield a path and all of its parents up to filesystem root.
Definition core.py:1896
str _workspace_asset_set_path(str workspace_root, str case_path)
Return the mutable asset-set pointer associated with a case config name.
Definition core.py:15902
str workspace_artifact_root(str workspace_root, str kind)
Return the canonical workspace-owned root for runs or studies.
Definition core.py:904
get_post_field_statistics_artifacts(dict post_cfg, str run_dir)
Predict the per-window statistics artifacts a recipe will produce.
Definition core.py:3556
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.
Definition core.py:13496
sync_case_binaries(str case_dir, str source_project_root)
Copy current source-repo binaries into a case directory for version-pinning.
Definition core.py:2365
dict _source_build_identity(str release_version)
Resolve reproducible release, commit, and dirty-tree build identity.
Definition core.py:218
dict load_workspace_config(str workspace_root)
Load and validate the immutable workspace identity/configuration file.
Definition core.py:455
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...
Definition core.py:10342
_parse_int_loose(value)
Best-effort integer parsing for summary extraction.
Definition core.py:17474
str resolve_post_spectra_output_dir(monitor_cfg=None)
Resolve the run-relative directory spectra CSVs are written to.
Definition core.py:3379
"tuple[dict, list[int]]" _parse_profiling_timestep_csv(str filepath)
Parse profiling timestep CSV into latest rows by step plus observed order.
Definition core.py:18082
bool _working_tree_has_tracked_changes(str run_dir)
Return True when the repository has staged or unstaged tracked changes.
Definition core.py:13433
_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.
Definition core.py:19619
list parse_case_index_tsv(str tsv_path)
Parse a case_index.tsv file back into a list of case entry dicts.
Definition core.py:16807
None ensure_workspace_layout(str workspace_root)
Materialize the uniform, cheap directory skeleton for one workspace.
Definition core.py:608
str normalize_statistics_task(str task_name)
Normalizes user-facing statistics task names to C pipeline keywords.
Definition core.py:5087
bool _post_requests_particle_output(dict post_cfg)
Return whether the current post recipe expects particle VTP output artifacts.
Definition core.py:3881
str _resolve_case_relative_path(str path_value, str case_dir)
Resolve a path relative to the current case directory.
Definition core.py:5714
str resolve_run_restart_dir(str run_dir, dict monitor_cfg)
Resolve the restart staging directory within a run directory.
Definition core.py:4493
extract_metric_from_csv(str case_dir, dict spec)
Extract a scalar metric from a CSV source.
Definition core.py:13882
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.
Definition core.py:6233
dict build_walltime_guard_exports("dict | None" cluster_cfg)
Build shell-evaluated environment exports for the runtime walltime guard.
Definition core.py:1427
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.
Definition core.py:4627
str _checkpoint_bundle_path(str source_dir, int step)
Resolve a run/output root or an exact checkpoint bundle.
Definition core.py:1192
validate_wall_model_pairing(dict case_cfg, les_cfg, rans_cfg, wall_cfg, str case_path, list errors, list warnings)
Rejects wall-model selections that no turbulence treatment can support.
Definition core.py:6400
dict materialize_run_assets(str run_dir, dict case_cfg, str case_path, bool require_precomputed=False, bool fetch_missing=False)
Resolve/build workspace assets and write the exact run input lock.
Definition core.py:15993
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.
Definition core.py:1853
reduce_metric_values(values, str reduction)
Reduce a metric series to one scalar according to the requested reducer.
Definition core.py:13860
_set_submission_stage_metadata(dict target_context, str stage_name, dict stage_meta)
Persist one stage's metadata back into the submission payload.
Definition core.py:20661
str run_initial_spectrum_generator(str field_path, str staged_grid, str spectrum_path, str case_dir)
Measure the shell-averaged spectrum of a staged initial condition.
Definition core.py:11382
_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.
Definition core.py:18618
list validate_grid_generator_cli_args(cli_args, str case_path)
Check closed-choice values inside the generator's opaque token list.
Definition core.py:5885
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.
Definition core.py:18702
str _face_artifact_token(str face)
Convert a BC face token into a filesystem-friendly artifact token.
Definition core.py:5387
str get_post_recipe_root(str run_dir, dict post_cfg)
Return the versioned run-local control directory for one post recipe.
Definition core.py:3828
_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.
Definition core.py:13445
absolutize_case_external_paths(dict case_cfg, str case_anchor_path)
Convert external grid/generator paths in case config to absolute paths.
Definition core.py:4943
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.
Definition core.py:1129
list read_picgrid_header_dimensions(str source_grid, int expected_nblk=None)
Read only the canonical PICGRID header dimensions.
Definition core.py:5232
str write_software_lock(str run_dir)
Write the run's software lock beside its asset lock.
Definition core.py:892
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.
Definition core.py:5593
write_json_file(str filepath, dict payload)
Write JSON metadata/manifests with a stable, readable format.
Definition core.py:1787
list _build_summary_plot_catalog(list records)
Build available qualified-series metadata from plot records.
Definition core.py:19987
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.
Definition core.py:20596
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.
Definition core.py:13211
str classify_run_directory_value(value)
Classify a configured run directory value.
Definition core.py:7274
version_workflow(args)
Report, and for the status action validate, the shared build identity.
Definition core.py:21573
str get_post_resume_state_path(str run_dir, dict post_cfg=None)
Return the JSON resume metadata path for a run directory.
Definition core.py:3816
str write_profile_info(str config_dir, list summaries)
Write a profile.info summary for generated inlet profiles.
Definition core.py:5810
list validate_reserved_directory_flags(dict config, str config_path, str label)
Reject raw PETSc passthrough options that set run-owned directories.
Definition core.py:7611
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.
Definition core.py:2454
bool make_args_include_explicit_goal("list[str]" make_args)
Return True when make args contain an explicit target rather than only options/assignments.
Definition core.py:2199
_stream_command_to_console_and_log(list command, str run_dir, log_file)
Stream command output to stdout and an already-open log file.
Definition core.py:13364
render_selected_summary(dict payload, str output_format="text")
Render selected timestep-independent config views and optional health.
Definition core.py:19399
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.
Definition core.py:2820
dict normalize_post_field_statistics_config(dict post_cfg)
Validate and canonicalize the field_statistics block of post.yml.
Definition core.py:3435
"list[tuple[str, str | None]]" _get_local_branches_with_upstreams(str run_dir)
Return local branch names plus their configured upstreams.
Definition core.py:13414
bool _launcher_arg_contains_whitespace(token)
Return True when a launcher arg token contains embedded whitespace and should be split.
Definition core.py:1822
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.
Definition core.py:4647
sweep_reaggregate_workflow(args)
Re-run metrics aggregation and plot generation for an existing study.
Definition core.py:17379
_workspace_yaml_role(str path)
Infer the owned workspace role of one copied YAML file.
Definition core.py:21210
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.
Definition core.py:1627
dict snapshot_run_configuration(str run_dir, dict source_paths, bool continuation=False)
Snapshot editable YAML inputs without erasing prior continuation state.
Definition core.py:934
_read_runtime_diagnostics_csv(path)
Yields (segment, row) for each data line of a runtime diagnostics CSV.
Definition core.py:19589
int _post_window_derived_field_count(dict window_cfg, list outputs)
Count the derived fields one window would produce for a set of outputs.
Definition core.py:3508
_find_named_file_upwards(str start, str filename)
Find a named file at or above an arbitrary filesystem anchor.
Definition core.py:420
str normalize_solution_convergence_mode(str value)
Normalizes the solution-convergence mode selector to the C-side canonical string.
Definition core.py:11073
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.
Definition core.py:20151
str compute_post_spectra_signature(dict spectra_cfg)
Reduce a normalized spectra recipe to a stable identity string.
Definition core.py:3369
list classify_physical_containment(str run_dir, dict values)
Classify where each run-owned directory physically lands.
Definition core.py:7503
normalize_boundary_conditions_layout(all_blocks_bcs, int num_blocks)
Normalize boundary_conditions to list-of-lists form and validate block count.
Definition core.py:6354
dict archive_active_generated_configuration(str run_dir, list paths)
Preserve generated control sidecars beside a continuation's YAML revision.
Definition core.py:991
str resolve_workspace_path(str anchor_file, str candidate, *bool allow_external=False)
Resolve a user path against its workspace and reject implicit escapes.
Definition core.py:571
str normalize_momentum_solver_type(str value)
Maps canonical user-facing momentum solver names to C-enum CLI values.
Definition core.py:10746
_restore_git_head(str run_dir, dict original_head, log_file)
Restore the repository back to the branch or detached commit it started on.
Definition core.py:13477
dict _read_previous_metric_rows(str results_dir)
Read the metrics table an earlier aggregation wrote, keyed by case id.
Definition core.py:13980
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.
Definition core.py:4750
get_post_source_data(dict post_cfg)
Return source_data as a mapping when valid, else an empty mapping.
Definition core.py:2721
str _build_post_lock_wrapper_source()
Return the Python wrapper used to hold an exclusive post-stage lock.
Definition core.py:4202
float _resolve_field_slice_velocity_scale(dict source, str case_dir)
Resolve field_slice dimensional velocity scale.
Definition core.py:5746
bool paths_overlap(str first, str second)
Whether two run-relative directories are the same or nested in one another.
Definition core.py:7319
None append_grid_da_processor_layout(list control_lines, dict grid_cfg, int num_procs)
Append optional global DMDA layout flags for any grid mode.
Definition core.py:10705
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.
Definition core.py:10584
validate_simulation_configs(dict case_cfg, dict solver_cfg, dict monitor_cfg, str case_path, str solver_path, str monitor_path)
Validates every configuration a simulation run consumes, before any work is done.
Definition core.py:7657
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.
Definition core.py:2839
bool _post_requests_eulerian_output(dict post_cfg)
Return whether the current post recipe expects Eulerian VTK output artifacts.
Definition core.py:3871
bool is_valid_email(str email)
Lightweight email validation for scheduler notifications.
Definition core.py:5076
"int | None" resolve_statistics_console_output_frequency(dict io_cfg)
Resolve the statistics console cadence, mirroring the particle one.
Definition core.py:10212
str _materialize_asset_file(str source, str destination)
Expose one immutable shared-asset file through reflink, hardlink, or copy.
Definition core.py:15955
list build_petsc_diagnostics_args(dict monitor_cfg, str run_dir, str stage_label)
Build PETSc diagnostics command-line arguments for a run stage.
Definition core.py:9921
dict _picgrid_geometry_summary(str path)
Read a canonical PICGRID and summarize what a user would want to check.
Definition core.py:15545
_read_yaml_if_exists(str filepath)
Read YAML when present, otherwise return None.
Definition core.py:17447
list warn_on_stale_runtime_binaries(dict identities)
Report native executables whose build identity is not the active source.
Definition core.py:1565
dict runtime_build_identities()
Read the build identity of every native executable a run would launch.
Definition core.py:1499
str resolve_path(str anchor_file, str candidate)
Resolve a potentially relative path against a source YAML file path.
Definition core.py:2626
str _post_output_directory_abs(str run_dir, dict post_cfg)
Resolve the absolute post output directory for the current recipe.
Definition core.py:3856
tuple _bc_profile_expected_dims(str face, tuple block_dims)
Return expected PICSLICE dimensions for a face and block node dimensions.
Definition core.py:6152
list preflight_config_directories(str root_dir)
Every config directory under a run or study root that may hold a control file.
Definition core.py:20745
None _require_clean_source_checkout(str action)
Refuse version-changing Git operations in a dirty source checkout.
Definition core.py:21629
_require_successful_command(list command, subprocess.CompletedProcess result)
Raise CommandExecutionError when a captured command failed.
Definition core.py:13340
"tuple[dict, list[int], dict]" _parse_runtime_memory_log(str filepath)
Parse Runtime_Memory.log into latest rows by step and final status.
Definition core.py:18114
discover_local_project_root(*extra_anchors)
Best-effort source repo discovery from runtime anchors.
Definition core.py:1926
_render_run_overview_text(dict summary)
Render run metadata as a compact dashboard.
Definition core.py:19207
_diagnostic_resolve_path_or_default(value, str run_dir, str default_filename)
Resolve true/string diagnostics values to a concrete file path.
Definition core.py:9813
list check_physical_containment(str run_dir, dict values)
Human-readable physical containment violations.
Definition core.py:7560
None add_planned_profile_artifacts(dict plan, dict case_cfg, str run_dir)
Add generated prescribed-flow profile artifacts to a dry-run plan.
Definition core.py:14890
"int | None" _find_previous_snapshot_step("list[int]" snapshot_steps, int step)
Return the nearest earlier snapshot step when available.
Definition core.py:18305
str _schema_key_hint(dict schema, tuple path, str key, set allowed)
Build a concise typo or hierarchy hint for an unsupported YAML key.
Definition core.py:6872
find_workspace_root(*anchors)
Locate the nearest initialized PICurv workspace for supplied anchors.
Definition core.py:440
None validate_programmatic_generated_ic_grid_settings(dict raw_settings)
Validate scalar programmatic grid settings needed by file-generating IC providers.
Definition core.py:10532
list expand_study_parameter_combinations(dict study_cfg)
Expand either cartesian-study parameters or explicit parameter sets.
Definition core.py:9061
None warn_on_grid_generator_hyphen_keys(dict generator, str case_path, list warnings)
Warn when grid.generator uses unsupported hyphenated wrapper keys.
Definition core.py:2687
dict submit_sbatch(str script_path, str dependency=None, str dependency_type="afterok")
Submit sbatch script and return submission metadata.
Definition core.py:9550
str directory_value_charset_problem(str value)
Describe why a directory value cannot be written to a PETSc options line.
Definition core.py:7257
"tuple[dict, dict, list[int]]" _parse_momentum_convergence_logs(str log_dir)
Parse per-block momentum convergence logs.
Definition core.py:17920
"int | None" resolve_particle_console_output_frequency(dict io_cfg)
Return the effective particle-console snapshot cadence from monitor.yml.
Definition core.py:12316
_extract_numeric_tuple(str text)
Extract a numeric tuple from a string like '(1, 2, 3)'.
Definition core.py:17511
bool discard_unused_run_directory(str run_dir, *bool created)
Remove a generated run directory that never received any content.
Definition core.py:773
validate_and_prepare_boundary_conditions(dict case_cfg)
Validate BC entries against currently supported C-side handlers/types and.
Definition core.py:6672
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.
Definition core.py:15444
"dict | None" _infer_study_plot_axis(dict study_cfg, list rows)
Infer the scientifically meaningful independent variable of a study.
Definition core.py:14144
submit_staged_local_run(args, dict target_context, list selected_stages)
Execute previously staged local run commands from scheduler/submission.json.
Definition core.py:20979
str _format_stage_list(list stage_names)
Format a human-readable stage list for submit diagnostics.
Definition core.py:20587
str run_initial_condition_generator(str case_path, str run_dir, dict resolved_ic)
Run the repository IC generator.
Definition core.py:11412
init_case(args)
Implements the 'init' command.
Definition core.py:21745
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.
Definition core.py:9629
_case_reynolds_number(dict case_cfg)
Reynolds number implied by a case's scaling and fluid properties.
Definition core.py:6479
int normalize_les_averaging_mode(value)
Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
Definition core.py:11887
persist_post_resume_state(str run_dir, dict plan, last_successful_requested_end_step=None)
Persist post resume lineage metadata for future –continue runs.
Definition core.py:4176
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.
Definition core.py:13804
append_les_parameter_flags(dict les_cfg, list control_lines)
Appends the LES closure parameter flags from a structured les block.
Definition core.py:12052
dict resolve_ic_cli_params(dict ic, int finit_code, prepared_blocks, float U_ref)
Resolve all IC parameters and return a dict of PETSc option values.
Definition core.py:11545
str _format_optional_step(step)
Format an optional step number for user-facing diagnostics.
Definition core.py:4078
"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.
Definition core.py:8397
str resolve_run_output_dir(str run_dir, dict monitor_cfg)
Resolve the output data directory within a run directory.
Definition core.py:4482
dict merge_execution_overrides("dict | None" base, "dict | None" override)
Merge execution overrides, letting explicit override values win key-by-key.
Definition core.py:2113
dict import_workspace_input(str workspace_root, str kind, str source, str name=None, str mode="copy")
Explicitly import or register one workspace input.
Definition core.py:21478
optional_matplotlib_pyplot()
Import matplotlib.pyplot lazily for study plot generation.
Definition core.py:156
get_post_input_extensions(dict post_cfg)
Return post input_extensions, preferring io.
Definition core.py:2743
dict build_case_asset_graph(dict case_cfg, str case_path)
Classify case inputs into precomputable or simulator-runtime providers.
Definition core.py:15248
list _asset_selection(dict graph, requested=None, *bool precomputable_only=False)
Resolve requested asset kinds plus dependency closure.
Definition core.py:15376
dict enforce_reproducibility_policy(str workspace_root)
Enforce an optional workspace policy demanding a clean, released build.
Definition core.py:513
_split_error_file_and_message(str raw_error)
Separate a validation error into its source-file and message fields when possible.
Definition core.py:1666
str _schema_path_text(tuple path)
Render an internal schema path tuple as a user-facing YAML path.
Definition core.py:6845
list _get_recorded_submission_stages(dict target_context)
Return stage names explicitly recorded in scheduler submission metadata.
Definition core.py:20564
"tuple[dict, dict, list[int]]" _parse_poisson_convergence_logs(str log_dir)
Parse per-block Poisson convergence logs.
Definition core.py:18043
sweep_workflow(args)
Study/sweep orchestration using Slurm job arrays.
Definition core.py:16833
str _summary_display_value(value)
Format one configuration-summary value for compact text output.
Definition core.py:19141
"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.
Definition core.py:9316
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.
Definition core.py:3943
str get_git_commit(str repo_root=None)
Best-effort git commit lookup for run/study manifests and case metadata.
Definition core.py:2152
_render_summary_plot_catalog(list catalog, str output_format)
Render available summarize plot-series metadata.
Definition core.py:20285
str generate_post_recipe_file(str run_dir, str run_id, dict post_cfg, dict source_files, monitor_cfg=None)
Generates a key=value config file (post.run) for the C post-processor.
Definition core.py:13179
dict _read_checkpoint_options(str metadata_path)
Parse the deliberately small PETSc-options checkpoint manifest.
Definition core.py:1205
inputs_workflow(args)
Handle explicit workspace input management.
Definition core.py:21554
render_slurm_array_stage_script(str script_path, str job_name, dict cluster_cfg, str array_spec, str case_index_tsv, str stage, str solver_exe, str post_exe, str stdout_path, str stderr_path)
Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
Definition core.py:13676
_mapping_value_with_aliases(dict mapping, *keys, default=None)
Return the first defined value from a mapping across alias keys.
Definition core.py:2658
append_turbulence_flags(dict models, list control_lines)
Appends turbulence model flags from legacy or structured case.yml blocks.
Definition core.py:12124
"tuple[str, int]" normalize_initial_condition_field(str value)
Normalize a file IC field selector to its staged basename and C enum value.
Definition core.py:11120
format_flag_value(value)
Converts Python types to C-style command-line flag values.
Definition core.py:10494
"set[int]" _scan_post_vtk_steps(str prefix_path, str extension)
Collect step numbers from VTK files named with a prefix, step suffix, and extension.
Definition core.py:3964
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.
Definition core.py:2288
dict _build_case_overview(dict context)
Build a curated case.yml summary with useful derived quantities.
Definition core.py:17661
bool _to_bool(value, str field_name)
Convert a YAML scalar/string to bool with a clear error message.
Definition core.py:6337
submit_staged_jobs(args)
Submit previously staged Slurm artifacts from an existing run/study directory.
Definition core.py:20826
int normalize_les_filter_width(value)
Maps LES grid-filter-width model names to the C -les_filter_width flag.
Definition core.py:11857
tuple resolve_unsafe_paths_override(dict dirs, str monitor_path)
Resolve the unsafe-paths override, requiring a real YAML boolean.
Definition core.py:7331
str aggregate_study_metrics(dict study_cfg, list cases, str results_dir)
Collect metric values from generated case directories into one CSV.
Definition core.py:13998
dict normalize_post_spectra_config(dict post_cfg)
Validate and canonicalize the spectra block of post.yml.
Definition core.py:2994
"tuple[dict, list[int]]" _parse_solution_convergence_log(str filepath)
Parse solution_convergence.log into latest rows by step plus observed order.
Definition core.py:18174
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.
Definition core.py:4122
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.
Definition core.py:13637
bool _post_requests_field_statistics(dict post_cfg)
Return whether the current post recipe derives accumulated field statistics.
Definition core.py:3543
dict _build_solver_overview(dict context)
Build a curated solver.yml summary with normalized selections.
Definition core.py:17739
sync_case_template_files(str case_dir, str template_dir, bool overwrite=False, bool prune=False, managed_rel_paths=None)
Sync template files into a case directory, preserving modified files unless overwrite is requested.
Definition core.py:2385
dict detect_post_completed_frontier(str run_dir, dict post_cfg, monitor_cfg, int start_step, int end_step, int step_interval)
Detect the highest contiguous fully completed post step for the current recipe.
Definition core.py:4041
write_yaml_file(str filepath, dict data)
Write YAML with stable ordering for generated study artifacts.
Definition core.py:1772
list _spectra_mean_arguments(dict task_cfg, dict bundle, dict mean_bundle=None)
Build the generator arguments implementing a task's fluctuation choice.
Definition core.py:3192
dict _parse_particle_snapshot_file(str filepath)
Parse sampled particle snapshots from a solver stream log.
Definition core.py:18254
source_workflow(args)
Fetch source history without silently changing the active code.
Definition core.py:21663
_read_json_if_exists(str filepath)
Read JSON when present, otherwise return None.
Definition core.py:17462
str _resolve_generator_script(str configured_script, str case_path, str default_name)
Resolve an optional generator script override or repository default.
Definition core.py:5415
str _command_to_string(list command_tokens)
Render a command list as a shell-safe display string.
Definition core.py:14409
float _to_float(value, str field_name)
Convert a YAML scalar to float with a clear error message.
Definition core.py:6310
str _stable_mapping_sha256(payload)
Hash a JSON-compatible value with deterministic serialization.
Definition core.py:15202
dict _require_summary_config(dict context, str name)
Return one explicitly requested copied config or fail with a structured error.
Definition core.py:17589
str parse_slurm_job_id(str sbatch_output)
Extract numeric job id from standard sbatch output.
Definition core.py:9541
str resolve_command_log_path(str run_dir, str log_filename)
Resolve a command log filename relative to the run directory.
Definition core.py:13280
"float | None" _summary_physical_time(dict context, dict record)
Resolve a record's physical time from its artifact or copied case configuration.
Definition core.py:19572
"tuple[list, dict]" build_post_locked_command(str run_dir, str recipe_fingerprint, list wrapped_command, bool create_wrapper=True)
Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
Definition core.py:4302
str _format_study_group_value(value)
Format a secondary study parameter compactly for a legend.
Definition core.py:14209
int normalize_flow_direction_token(str value)
Maps a face-token flow direction string to the C FlowDirection enum integer.
Definition core.py:11512
bool _study_use_log_scale(list values, bool semantic_hint=False)
Use log scaling only for positive data spanning a meaningful range.
Definition core.py:14268
int normalize_rans_model(value)
Maps RANS model selectors to the current C -rans switch.
Definition core.py:11981
str resolve_runtime_executable(str executable_name)
Resolve solver/post executable path, preferring local sibling binaries.
Definition core.py:1442
list _asset_payload_files(str build_root, str kind)
Enumerate canonical files belonging to one asset kind in a build tree.
Definition core.py:15415
list _collect_summary_plot_records(dict context)
Collect append-ordered numeric records from summarize-supported scalar logs.
Definition core.py:19660
"list[set[int]]" collect_post_completion_families(str run_dir, dict post_cfg, monitor_cfg=None)
Collect per-family completed-step sets for the current post recipe.
Definition core.py:4007
"tuple[dict, list[int]]" _parse_continuity_metrics_log(str filepath)
Parse Continuity_Metrics.log into latest rows by step plus observed order.
Definition core.py:17819
sync_case_config_command(args)
Refresh template-managed config/docs files in a case directory.
Definition core.py:21846
dict enforce_workspace_version(str workspace_root)
Enforce an optional workspace PICurv version requirement.
Definition core.py:475
str resolve_recipe_spectra_output_dir(dict post_cfg, monitor_cfg=None)
Resolve the canonical spectra directory for one versioned recipe.
Definition core.py:3389
list build_identity_problems(dict identities, workspace_requirement=None)
Report every reason the active build identity is not internally coherent.
Definition core.py:1519
build_project(args)
Implements the 'build' command.
Definition core.py:21938
validate_eulerian_checkpoint(str source_dir, int step, dict monitor_cfg)
Validate the mandatory Eulerian field set required by ReadSimulationFields().
Definition core.py:4603
int normalize_field_init_mode(str value)
Maps canonical field init mode names to C enum/int codes (-finit).
Definition core.py:11098
require_project_root(str candidate, str purpose)
Validate that a source repo root was resolved and is structurally valid.
Definition core.py:2268
"list[str]" strip_launcher_size_flags(str launcher_name, "list[str]" launcher_args)
Remove explicit MPI task-count flags from known launchers.
Definition core.py:9353
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.
Definition core.py:4698
str allocate_generated_run_id(str runs_root, dict case_cfg, str case_path)
Build the generated run identity, disambiguating a same-second collision.
Definition core.py:751
fail_cli_usage(str message, str hint=None)
Emit a structured CLI usage error and exit with code 2.
Definition core.py:1650
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.
Definition core.py:15850
dict _diagnostic_info(value)
Validate PETSc info logging configuration.
Definition core.py:9734
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.
Definition core.py:5125
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.
Definition core.py:15804
_drop_imported_package(str package_name)
Remove a failed/partial import package tree from sys.modules.
Definition core.py:115
_nearest_step("set[int]" steps, int target)
Return the complete source step nearest to a target step.
Definition core.py:4066
_print_config_header(str title, "str | None" subtitle=None)
Print a strong dashboard-style configuration summary header.
Definition core.py:19162
str _asset_file_sha256(str path)
Hash an asset source or payload without loading it into memory.
Definition core.py:15189
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.
Definition core.py:11467
dict resolve_fluid_scaling(dict case_cfg)
Resolve the shared physical and nondimensional fluid scaling contract.
Definition core.py:11159
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.
Definition core.py:5290
str _sanitize_error_field(value)
Normalize error fields into a single-line string.
Definition core.py:1612
dict _get_submission_stage_metadata(dict target_context, str stage_name)
Return stored metadata for one staged submission target.
Definition core.py:20544
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.
Definition core.py:8467
dict validate_newton_krylov_config(dict cfg)
Validate and normalize the structured Newton–Krylov solver block.
Definition core.py:10772
bool has_explicit_monitor_whitelist(dict monitor_cfg)
Return True when logging.enabled_functions contains at least one entry.
Definition core.py:9656
dict read_binary_build_identity(str executable_path)
Read the build identity a native executable was compiled with.
Definition core.py:1460
tuple staged_control_directories(str control_path)
Read run-owned directory values from a staged control file.
Definition core.py:20696
set _les_periodic_axes(dict case_cfg)
Reports which logical axes a case declares periodic on both faces.
Definition core.py:6379
bool is_project_root(str candidate)
Return True when a directory looks like the PICurv source repository root.
Definition core.py:1879
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.
Definition core.py:9393
int normalize_les_test_filter(value)
Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
Definition core.py:11828
str normalize_les_averaging_directions(value)
Maps a list of homogeneous logical directions to the C flag's string form.
Definition core.py:11950
validate_cluster_config(dict cluster_cfg, str cluster_path)
Validate Slurm scheduler configuration from cluster.yml.
Definition core.py:8749
subprocess.CompletedProcess _run_captured_command(list command, str run_dir)
Run a command and capture combined stdout/stderr details for later inspection.
Definition core.py:13319
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.
Definition core.py:2175
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.
Definition core.py:9830
list_source_binaries(str source_project_root)
List binary artifacts currently available in the source repo bin directory.
Definition core.py:2347
bool _post_needs_particle_source(dict post_cfg)
Return whether the current post recipe requires particle source files to be present.
Definition core.py:3900
_print_config_group(str title, list rows)
Print an aligned configuration-summary field group.
Definition core.py:19175
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.
Definition core.py:2232
_iter_nonempty_noncomment_lines(file_obj)
Yield (lineno, stripped_line) for non-empty, non-comment lines.
Definition core.py:5102
str normalize_eulerian_field_source(str value)
Normalizes the Eulerian field source selector to the C-side canonical string.
Definition core.py:11646
dict build_software_lock()
Capture the exact software identity a run is about to execute with.
Definition core.py:809
dict load_active_run_configuration(str run_dir)
Load the active immutable configuration revision for a run.
Definition core.py:970
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.
Definition core.py:21278
str case_run_label(dict case_cfg, str case_path)
Resolve the stable human-facing portion of a generated run identifier.
Definition core.py:733
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.
Definition core.py:1090
_diagnostic_bool_or_path(value, str key)
Validate a diagnostics value that can be false, true, or a path/viewer string.
Definition core.py:9763
dict resolve_profiling_config(dict monitor_cfg)
Resolve profiling reporting config from monitor.yml.
Definition core.py:9666
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.
Definition core.py:6900
bool _write_structured_grid_preview(str picgrid_path, str destination, dims)
Write a single-block ASCII VTS preview of a staged PICGRID.
Definition core.py:15582
dict _build_asset_inspection(str kind, str build_root, dict provider, list payload_files)
Produce the inspection material published beside an asset's payload.
Definition core.py:15629
dict get_post_lock_paths(str run_dir, str recipe_id=None)
Return lock-wrapper related paths for a run directory.
Definition core.py:3840
"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.
Definition core.py:3925
str _classify_error_code(str message)
Map existing validation/error messages to the standardized code set.
Definition core.py:1705
subprocess.CompletedProcess _git_source_command(list arguments)
Run a checked Git command against the active PICurv source checkout.
Definition core.py:21648
"tuple[str | None, list[str]]" normalize_cluster_launcher(dict execution)
Canonicalize cluster launcher config into executable token plus argv-style flags.
Definition core.py:9340
dict _compute_particle_snapshot_delta("list[dict]" current_rows, "list[dict]" previous_rows)
Compute sampled deltas between two particle snapshot samples.
Definition core.py:18318
"str | None" resolve_runtime_execution_seed_source(str source_project_root)
Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
Definition core.py:1831
"list[str]" _find_solver_stream_log_candidates(str run_dir, str log_dir)
Return plausible solver stream logs for local and Slurm runs.
Definition core.py:18232
str _summary_field_label(str field)
Return the report-facing label for one logged scalar field.
Definition core.py:19563
_lookup_allowed_schema_keys(dict schema, tuple path)
Return allowed keys for a path, honoring '*' dynamic mapping entries.
Definition core.py:6854
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.
Definition core.py:18395
int normalize_wall_function_model(value)
Maps wall-function model selectors to the C -wallfunction flag.
Definition core.py:12009
dict _build_monitor_overview(dict context)
Build a curated monitor.yml summary with resolved defaults.
Definition core.py:17776
dict _provider_source_fingerprints(value, str case_path, str key="")
Hash every existing file explicitly referenced by an asset provider.
Definition core.py:15212
bool _ic_has_inlet(prepared_blocks)
Return True if any prepared BC block contains an INLET face.
Definition core.py:11531
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.
Definition core.py:5645
str write_runtime_execution_file(str filepath, str template_source_path=None)
Write a default runtime execution config, copying a source template when available.
Definition core.py:1804
str compute_physical_case_identity(dict case_cfg)
Compute the hidden identity used to guard in-place continuation.
Definition core.py:4504
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.
Definition core.py:14922
dict validate_petsc_vec_binary(str path)
Validate the basic PETSc binary VecView envelope used by ReadFieldData.
Definition core.py:11360
"tuple[float, float] | None" _study_linear_y_limits(list values)
Build padded linear limits that include zero for non-negative metrics.
Definition core.py:14281
str get_post_statistics_output_prefix(dict post_cfg, str default="Stats")
Resolve the statistics CSV prefix, preserving legacy top-level override support.
Definition core.py:2800
_print_validation_errors(list errors)
Prints validation errors and exits.
Definition core.py:9583
detect_last_checkpoint_step(str output_dir)
Scan output directory for the highest step number available.
Definition core.py:4616
dict build_post_execution_plan(str run_dir, str run_id, dict case_cfg, dict monitor_cfg, dict post_cfg, bool continue_requested=False, bool allow_source_frontier_scan=True)
Resolve post resume/source-availability behavior into one execution plan.
Definition core.py:4332
_invoke_plot_gen(dict request)
Invoke standalone plot.gen with one normalized request over stdin.
Definition core.py:20302
list get_study_parameter_keys(dict study_cfg)
Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter s...
Definition core.py:9103
"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.
Definition core.py:4087
dict resolve_grid_da_processor_layout(dict grid_cfg)
Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
Definition core.py:10651
dict flatten_study_parameters(dict parameters)
Flatten grouped study overrides into scalar dotted-path columns.
Definition core.py:9073
str format_command_for_display(list command)
Render a shell-safe command string for console and log output.
Definition core.py:13271
_diagnostic_bool_or_all(value, str key)
Validate a diagnostics value that can be false, true, or "all".
Definition core.py:9789
"dict | None" resolve_walltime_guard_policy("dict | None" cluster_cfg)
Resolve the effective Slurm walltime-guard policy for generated solver jobs.
Definition core.py:1397
dict resolve_solution_monitoring_flags(dict monitor_cfg)
Translate solution-monitoring YAML into the existing C convergence flags.
Definition core.py:10278
auto_identify_run_inputs(str config_dir)
Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
Definition core.py:13604
dict _resolve_submission_target(str run_dir=None, str study_dir=None)
Resolve a run/study submission target from explicit directory flags.
Definition core.py:20458
tuple validate_run_directory_structure(str run_dir)
Refuse a run whose root has grown a directory the layout does not define.
Definition core.py:666
bool _post_requests_statistics(dict post_cfg)
Return whether the current post recipe expects statistics CSV artifacts.
Definition core.py:3891
str _humanize_plot_identifier(str value)
Convert one machine-oriented identifier into a readable plot label.
Definition core.py:19547
sweep_continue_workflow(args)
Continue a partially-completed Slurm parameter sweep study.
Definition core.py:17125
bool _is_summary_plot_continuation_marker(str line)
Return whether a log line starts a new continuation segment.
Definition core.py:19651
get_post_run_control_value(dict post_cfg, str canonical_key, default=None)
Resolve post run_control values with backwards-compatible legacy aliases.
Definition core.py:2674
str generate_header(str run_id, dict source_files)
Creates a standard header block for all generated files.
Definition core.py:9602
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.
Definition core.py:5927
str _study_metric_label(dict study_cfg, str metric)
Resolve an optional configured metric label or humanize its name.
Definition core.py:14107
list reject_generator_destination_keys(generator, str case_path, str label)
Reject generator settings that try to choose their own output destination.
Definition core.py:2703
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.
Definition core.py:3172
dict resolve_initial_condition_config(dict ic, prepared_blocks, float U_ref, provider_context=None)
Resolve legacy and structured initial-condition YAML into one launcher contract.
Definition core.py:11183
str normalize_analytical_type(str value)
Normalizes the analytical solution selector to the C-side canonical string.
Definition core.py:11670
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.
Definition core.py:14418
render_slurm_script(str script_path, str job_name, dict cluster_cfg, list command, str workdir, str stdout_path, str stderr_path=None, dict env_vars=None, dict shell_env_vars=None, str array_spec=None, list follow_commands=None)
Render a Slurm batch script for a single command.
Definition core.py:9210
bool _statistics_subsystem_available(dict case_cfg, requirement)
Report whether the subsystem a statistics field depends on is active.
Definition core.py:9962
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.
Definition core.py:20024
str format_picgrid_coordinate(float value)
Format a coordinate with round-trip-safe binary64 precision.
Definition core.py:5116
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.
Definition core.py:5396
dict normalize_post_recipe_signature(dict recipe_cfg)
Normalize post recipe settings into a stable signature mapping.
Definition core.py:3765
list build_spectra_follow_command(str run_dir, str post_path, dict post_cfg)
Build the batch-script step that measures spectra after the field stage.
Definition core.py:9170
"tuple[dict, list[int]]" _parse_particle_metrics_log(str filepath)
Parse Particle_Metrics.log into latest rows by step plus observed order.
Definition core.py:17864
"set[int]" _scan_post_statistics_csv_steps(str csv_path)
Scan step ids from the first CSV column of a statistics artifact.
Definition core.py:3984
normalize_metric_spec(metric)
Normalize study metric definitions to a common dictionary form.
Definition core.py:13963
dict _normalize_prescribed_flow_source(source, str field_name)
Validate the structured source block for prescribed_flow BCs.
Definition core.py:6097
get_post_statistics_task_tokens(dict post_cfg)
Return normalized statistics pipeline tokens that will be written into post.run.
Definition core.py:2761
None validate_continue_case_identity(str run_dir, dict case_cfg)
Reject in-place continuation when the physical case has changed.
Definition core.py:4524
dict _publish_asset_object(str workspace_root, str build_root, dict provider, list payload_files)
Publish one immutable content-addressed asset object atomically.
Definition core.py:15717
float _to_finite_float(value, str field_name)
Convert a non-boolean YAML scalar to a finite float.
Definition core.py:6323
infer_plot_x_axis(dict study_cfg, list rows)
Infer x-axis key/values for study plots.
Definition core.py:14196
str _read_release_version()
Read the single release version shared by every PICurv executable.
Definition core.py:202
parse_and_add_model_flags(dict case_cfg, list control_lines)
Parses the 'models' section of case.yml and adds corresponding C-solver flags.
Definition core.py:12326
"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.
Definition core.py:4106
_relative_to_workspace(str path, str workspace_root)
Return a portable workspace-relative path when possible.
Definition core.py:918
str _format_summary_float(value, str spec=".6e", str missing="n/a")
Format optional numeric values for summary text output.
Definition core.py:18658
str _workspace_asset_set_name(str workspace_root, str case_path)
Return a collision-free, readable mutable asset-set name.
Definition core.py:15790
find_project_root_upwards(str start_path)
Search upward from an anchor and return the first matching project root.
Definition core.py:1912
dict normalize_solution_monitoring_config(dict monitor_cfg)
Validate and canonicalize physical-solution convergence monitoring.
Definition core.py:10223
dict _normalize_field_slice_source(source, str field_name)
Validate a prescribed_flow field_slice source block.
Definition core.py:5458
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.
Definition core.py:6501
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....
Definition core.py:9456
generate_study_plots(dict study_cfg, str metrics_csv, str plots_dir)
Generate metric-vs-parameter plots for completed studies.
Definition core.py:14296
dict _run_component_states(str run_dir, dict stages_requested)
Report each run component's home, retention class, and lifecycle state.
Definition core.py:1045
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.
Definition core.py:2733
dict _get_git_head_state(str run_dir)
Capture the current git HEAD branch name and commit hash.
Definition core.py:13402
render_run_dry_plan(dict plan, str output_format="text")
Render dry-run plan in human or JSON format.
Definition core.py:14963
list resolve_field_statistics_flags(dict monitor_cfg, dict case_cfg=None)
Serialize field-statistics configuration into control-file option lines.
Definition core.py:10173
list resolve_conductor_entry_point()
Resolve how to invoke this conductor again from a batch script.
Definition core.py:9154
dict build_run_dry_plan(args)
Build a no-write execution plan for run --dry-run.
Definition core.py:14433
_file_sha256(str path)
Content digest of one file, or None when it cannot be read.
Definition core.py:793
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.
Definition core.py:2065
list _study_plot_groups(dict study_cfg, list rows, dict axis, str metric)
Group metric points by any secondary varied study parameters.
Definition core.py:14224
dict _normalize_field_slice_selector(slice_cfg, str field_name)
Validate the field_slice slice selector.
Definition core.py:5524
_render_solver_summary_text(dict summary)
Render the solver summary as a glanceable numerical-method dashboard.
Definition core.py:19306
append_passthrough_flags(list control_lines, dict options)
Appends raw CLI flags to the control list from a {flag: value} dict.
Definition core.py:12200
float _summary_source_mtime(paths)
Return the newest modification time among one or more summary sources.
Definition core.py:18671
str initialize_workspace_root(str workspace_root, str template_name)
Create the workspace skeleton and its identity file at one root.
Definition core.py:617
tuple evaluate_run_directories(dict values, bool override, set explicit=None)
Apply every run-directory safety rule to a set of effective directory values.
Definition core.py:7356
dict build_post_recipe_config(dict post_cfg, monitor_cfg=None)
Build the flat key=value mapping consumed by the C post-processor.
Definition core.py:3589
find_case_origin_metadata_file(str case_dir_hint=None)
Find the nearest case-origin metadata file from known runtime anchors.
Definition core.py:1947
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.
Definition core.py:5768
_prune_incompatible_python_site_paths(paths)
Remove site-package paths for a different Python major/minor version.
Definition core.py:95
_choose_primary_workspace_role(list candidates, str role, str template_name)
Select the canonical role file from a template that may carry variants.
Definition core.py:21253
tuple preflight_staged_run_directories(str root_dir)
Re-check run-directory safety against an already-staged run or study.
Definition core.py:20767
_deep_set(dict container, str dotted_path, value)
Set nested dictionary value, creating intermediate maps when needed.
Definition core.py:9032
cancel_run_jobs(args)
Cancel Slurm-submitted jobs for an existing run directory.
Definition core.py:21085
str normalize_extension(str ext)
Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
Definition core.py:9144
dict validate_committed_checkpoint(str source_dir, int step, bool require_particles=False)
Validate one committed bundle using the same manifest contract as C.
Definition core.py:1228
int normalize_interpolation_method(str value)
Maps interpolation method names to C enum/int codes (-interpolation_method).
Definition core.py:11772
None add_planned_grid_artifacts(dict plan, dict case_cfg, str run_dir)
Add grid-mode-specific staged artifacts to a dry-run plan.
Definition core.py:14863
bool resolve_enabled_flag(dict cfg, str path, bool default=True)
Resolves a structured enabled flag and rejects non-boolean values.
Definition core.py:12037
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.
Definition core.py:12907
dict _toolchain_identity()
Best-effort record of the PETSc, MPI, and compiler the binaries were built on.
Definition core.py:849
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.
Definition core.py:18570
dict _build_summary_context(str run_dir)
Resolve run-local config and artifact paths for summarize.
Definition core.py:17522
dict effective_run_directories(dict configured)
Fill in defaults for run-owned directories that were not configured.
Definition core.py:7570
extract_metric_from_log(str case_dir, dict spec)
Extract a scalar metric from a log file using regex.
Definition core.py:13932
load_case_origin_metadata(str case_dir_hint=None)
Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
Definition core.py:1969
str normalized_run_directory(str value)
Normalized, comparable form of a contained run directory value.
Definition core.py:7310
str _extract_key_path(str message)
Best-effort key-path extraction from free-form validation messages.
Definition core.py:1684
_iter_post_steps(int start_step, int end_step, int step_interval)
Yield configured post-processing steps inclusively.
Definition core.py:3910
dict resolve_solver_monitoring_flags(dict monitor_cfg)
Resolve human-readable solver monitoring YAML to raw control flags.
Definition core.py:12243
dict plan_run_assets(dict case_cfg, str case_path)
Plan reuse/build/runtime actions for all configured run providers.
Definition core.py:15913
list get_post_spectra_output_artifacts(dict post_cfg, str run_dir, monitor_cfg=None)
Predict the spectra CSV paths a recipe will write.
Definition core.py:3400
str post_spectra_task_basename(dict task_cfg, str output_prefix)
Build the file basename one normalized spectra task writes.
Definition core.py:3423
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.
Definition core.py:6172
_workspace_requested_version(str workspace_root)
Resolve an exact version requested by an initialized workspace.
Definition core.py:21675
str _diagnostic_default_file(str run_dir, str filename)
Return an absolute run-local diagnostics file path.
Definition core.py:9803
"set[int]" _scan_committed_checkpoint_steps(str source_dir, bool require_particles=False)
Return only fully validated, committed checkpoint steps.
Definition core.py:1315
_rewrite_workspace_path_values(value, dict replacements)
Rewrite copied template path scalars to workspace-root-relative homes.
Definition core.py:21236
require_numpy()
Import NumPy only for commands that need numeric reductions.
Definition core.py:126
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.
Definition core.py:10297
list _flatten_summary_mapping(dict mapping, str prefix="")
Flatten nested summary mappings into readable dotted field rows.
Definition core.py:19190
None enforce_run_directory_structure(str run_dir)
Apply validate_run_directory_structure() as a refusal at run time.
Definition core.py:711
dict run_post_spectra_stage(str run_dir, dict post_cfg, dict monitor_cfg, str source_dir, steps, bool quiet=False)
Measure spectra for every requested task across a window of committed steps.
Definition core.py:3246
versions_workflow(args)
List or activate a release using the existing source/build owners.
Definition core.py:21696
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.
Definition core.py:9481
bool is_generated_ic_provider(dict resolved_ic)
Return whether a resolved IC is backed by a registered file generator.
Definition core.py:11150
validate_particle_checkpoint(str source_dir, int start_step, dict monitor_cfg)
Validate that particle checkpoint files exist for the given step.
Definition core.py:4672
str _study_parameter_label(str key)
Return a concise report label for a study parameter path.
Definition core.py:14090
parse_post_recipe_file(str post_recipe_path)
Parse an existing generated post.run file into a key/value mapping.
Definition core.py:3797
"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.
Definition core.py:11716
int parse_slurm_time_limit_to_seconds(str time_text)
Parse a Slurm time-limit string into total seconds.
Definition core.py:1340
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.
Definition core.py:5009
None ensure_run_layout(str run_dir)
Materialize the uniform, cheap directory skeleton for one run.
Definition core.py:646
int get_cluster_total_tasks(dict cluster_cfg)
Return cluster total tasks.
Definition core.py:9135
dict read_monitor_from_run(str run_dir)
Read the monitor.yml from a run directory's config/ subdirectory.
Definition core.py:4686
dict normalize_field_statistics_config(dict monitor_cfg, dict case_cfg=None)
Validate and canonicalize the field-statistics block of monitor.yml.
Definition core.py:9988
"list[list[int]]" _order_summary_step_orders("list[tuple[list[int], object]]" sources)
Order observed step sequences by the recency of their source files.
Definition core.py:18688
bool _diagnostic_bool(value, str key)
Validate a diagnostics boolean value.
Definition core.py:9777
"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.
Definition core.py:11689
"list | None" _numeric_study_column(list rows, str key)
Parse one complete, finite numeric study-table column.
Definition core.py:14125
print_case_source_status(dict status)
Render human-readable source/case drift details.
Definition core.py:2554
dict read_yaml_file(str filepath)
Safely reads a YAML file and returns its content.
Definition core.py:1744
precompute_workflow(args)
Resolve, preflight, and atomically publish reusable workspace assets.
Definition core.py:16103
dict parse_solver_config(dict solver_cfg)
Parses the structured solver.yml into a flat dictionary of {flag: value}.
Definition core.py:12389
run_workflow(args)
Main orchestrator for the 'run' command (local and Slurm modes).
Definition core.py:16136
dict resolve_runtime_execution_context(dict runtime_execution_cfg, str context)
Resolve default plus context-specific execution overrides.
Definition core.py:2137
pull_source_repo(args)
Refresh source branches in the repository resolved from a case directory.
Definition core.py:21898
"tuple[dict, str]" apply_canonical_post_paths(dict post_cfg, str run_dir)
Route every post artifact into its fixed analysis or visualization home.
Definition core.py:3729
list _collect_spectra_plot_records(dict context)
Collect the per-step scalar histories written by the spectra post stage.
Definition core.py:19941
list validate_post_spectra_preconditions(dict spectra_cfg, dict case_cfg, str post_path)
Check spectra tasks against what the case can actually support.
Definition core.py:3111
str compute_post_recipe_id(dict post_cfg)
Compute a stable human-readable identity for one post recipe.
Definition core.py:3701
resolve_template_directory(str source_project_root, str template_name)
Resolve an example template directory inside the source repository.
Definition core.py:2308
Head of a generic C-style linked list.
Definition variables.h:475