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 functools
28import itertools
29import re
30import shlex
31import copy
32import contextlib
33import math
34import difflib
35import warnings
36from datetime import datetime
37from io import StringIO
38import time
39import filecmp
40import importlib.util
41import errno
42import tempfile
43from pathlib import Path
44from xml.etree import ElementTree
45
46try:
47 from .storage import (
48 StorageError,
49 cold_study_members,
50 restore_cold_study_members,
51 is_artifact_cold,
52 read_artifact_identity,
53 STORAGE_LOCK_FILENAME,
54 STORAGE_STATE_FILENAME,
55 require_storage_payload_local,
56 runtime_stage_lock,
57 storage_state_summary,
58 _rebase_restored_text_paths,
59 )
60except ImportError:
61 # White-box tests also load core.py directly rather than as a package module. The
62 # storage package resolves its own siblings by relative import, so it is loaded by
63 # putting its parent on the path rather than by file location.
64 _package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
65 if _package_parent not in sys.path:
66 sys.path.insert(0, _package_parent)
67 _storage_module = importlib.import_module("picurv_cli.storage")
68 StorageError = _storage_module.StorageError
69 cold_study_members = _storage_module.cold_study_members
70 restore_cold_study_members = _storage_module.restore_cold_study_members
71 is_artifact_cold = _storage_module.is_artifact_cold
72 read_artifact_identity = _storage_module.read_artifact_identity
73 STORAGE_LOCK_FILENAME = _storage_module.STORAGE_LOCK_FILENAME
74 STORAGE_STATE_FILENAME = _storage_module.STORAGE_STATE_FILENAME
75 require_storage_payload_local = _storage_module.require_storage_payload_local
76 runtime_stage_lock = _storage_module.runtime_stage_lock
77 storage_state_summary = _storage_module.storage_state_summary
78 _rebase_restored_text_paths = _storage_module._rebase_restored_text_paths
79
80_NUMPY_MODULE = None
81_MATPLOTLIB_PYPLOT = None
82
83
85 """!
86 @brief Module-like proxy that preserves `picurv.np` without eager import.
87 """
88
89 def __getattr__(self, name):
90 """!
91 @brief Resolve a NumPy attribute on first use.
92 @param[in] name NumPy attribute name.
93 @return Requested NumPy attribute.
94 """
95 return getattr(require_numpy(), name)
96
97
99
100
102 """!
103 @brief Remove site-package paths for a different Python major/minor version.
104 @param[in] paths Candidate sys.path entries.
105 @return Filtered path list.
106 """
107 current = (sys.version_info[0], sys.version_info[1])
108 pattern = re.compile(r"python(?:-)?(\d+)\.(\d+)", re.IGNORECASE)
109 filtered = []
110 for path in paths:
111 text = str(path)
112 match = pattern.search(text)
113 if match:
114 path_version = (int(match.group(1)), int(match.group(2)))
115 if path_version != current and ("site-packages" in text or "dist-packages" in text):
116 continue
117 filtered.append(path)
118 return filtered
119
120
121def _drop_imported_package(package_name: str):
122 """!
123 @brief Remove a failed/partial import package tree from sys.modules.
124 @param[in] package_name Top-level package name.
125 """
126 prefix = package_name + "."
127 for module_name in list(sys.modules):
128 if module_name == package_name or module_name.startswith(prefix):
129 sys.modules.pop(module_name, None)
130
131
133 """!
134 @brief Import NumPy only for commands that need numeric reductions.
135 @return Imported NumPy module.
136 """
137 global _NUMPY_MODULE
138 if _NUMPY_MODULE is not None:
139 return _NUMPY_MODULE
140 try:
141 import numpy
142 except Exception as exc:
143 first_error = exc
144 original_path = list(sys.path)
145 try:
147 sys.path = _prune_incompatible_python_site_paths(original_path)
148 import numpy
149 except Exception as retry_exc:
150 raise RuntimeError(
151 "NumPy is required for this operation, but no compatible NumPy "
152 "could be imported for this Python interpreter. PICurv ignored "
153 "site-packages paths for other Python versions and retried. "
154 f"First error: {first_error}. Retry error: {retry_exc}"
155 ) from retry_exc
156 finally:
157 sys.path = original_path
158 _NUMPY_MODULE = numpy
159 return _NUMPY_MODULE
160
161
163 """!
164 @brief Import matplotlib.pyplot lazily for study plot generation.
165 @return matplotlib.pyplot when available, otherwise None.
166 """
167 global _MATPLOTLIB_PYPLOT
168 if _MATPLOTLIB_PYPLOT is not None:
169 return _MATPLOTLIB_PYPLOT
170 original_path = list(sys.path)
171 try:
172 import matplotlib.pyplot as pyplot
173 except Exception:
174 try:
175 _drop_imported_package("matplotlib")
176 sys.path = _prune_incompatible_python_site_paths(original_path)
177 import matplotlib.pyplot as pyplot
178 except Exception:
179 return None
180 finally:
181 sys.path = original_path
182 _MATPLOTLIB_PYPLOT = pyplot
183 return _MATPLOTLIB_PYPLOT
184
185# --- Global Path Definitions ---
186# The implementation package and source-tree entrypoint live in picurv_cli/,
187# while generators/ owns standalone generators.
188PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))
189PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)
190INVOKED_SCRIPT_DIR = os.environ.get(
191 "_PICURV_INVOKED_SCRIPT_DIR",
192 PACKAGE_PATH,
193)
194SCRIPT_PATH = os.environ.get(
195 "_PICURV_SCRIPT_PATH",
196 PACKAGE_PATH,
197)
198PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)
199GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT, "generators")
200if os.path.basename(SCRIPT_PATH) == "bin":
201 DEFAULT_BIN_DIR = SCRIPT_PATH
202else:
203 DEFAULT_BIN_DIR = os.path.join(PROJECT_ROOT, "bin")
204
205VERSION_FILE = os.path.join(PACKAGE_PROJECT_ROOT, "VERSION")
206
207
209 """!
210 @brief Read the single release version shared by every PICurv executable.
211 @return Release version from VERSION, or a safe development fallback.
212 """
213 configured = os.environ.get("PICURV_RELEASE_VERSION")
214 if configured:
215 return configured.strip()
216 try:
217 with open(VERSION_FILE, "r", encoding="utf-8") as stream:
218 value = stream.read().strip()
219 except OSError:
220 value = "0.0.0"
221 return value or "0.0.0"
222
223
224def _git_output(*arguments) -> "str | None":
225 """!
226 @brief Run one git command in the source checkout.
227 @param[in] arguments Git arguments.
228 @return Stripped standard output, or None when git is unavailable or the command fails.
229 """
230 try:
231 result = subprocess.run(["git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
232 text=True, capture_output=True, check=False)
233 except OSError:
234 return None
235 return result.stdout.strip() if result.returncode == 0 else None
236
237
238def _source_build_identity(release_version: str) -> dict:
239 """!
240 @brief Resolve reproducible release, commit, and dirty-tree build identity.
241 @details One `git describe --long --dirty` gives the commit, its distance from the
242 release tag, and whether tracked files differ from it (untracked files are
243 ignored, as `git status --untracked-files=no` did). Only when the tag is
244 missing does it fall back to the commit, a dirty check, and the commit count.
245 On a cold network filesystem each git call costs seconds, which is why this is
246 reached lazily through picurv_build() rather than at import.
247 @param[in] release_version Release version read from VERSION.
248 @return Mapping suitable for manifests and user-facing status output.
249 """
250 identity = {
251 "release_version": release_version,
252 "version": release_version,
253 "git_commit": None,
254 "git_short_commit": None,
255 "dirty": None,
256 "build_id": release_version,
257 "dev_distance": None,
258 "released": False,
259 }
260 described = _git_output("describe", "--tags", "--long", "--dirty", "--abbrev=40",
261 "--match", f"v{release_version}")
262 match = re.match(r"^v.+-(\d+)-g([0-9a-f]{40})(-dirty)?$", described or "")
263 if match:
264 identity["dev_distance"] = int(match.group(1))
265 identity["git_commit"] = match.group(2)
266 identity["dirty"] = bool(match.group(3))
267 else:
268 # No tag for this release yet: every commit is development toward it.
269 identity["git_commit"] = _git_output("rev-parse", "HEAD")
270 status = _git_output("status", "--porcelain", "--untracked-files=no")
271 identity["dirty"] = None if status is None else bool(status)
272 count = _git_output("rev-list", "--count", "HEAD")
273 if count is not None and count.isdigit():
274 identity["dev_distance"] = int(count)
275 if identity["git_commit"]:
276 identity["git_short_commit"] = identity["git_commit"][:12]
277 # Commits since the release tag, so a development build is visibly ahead of the
278 # release it names rather than claiming to be that release.
279 identity["released"] = identity["dev_distance"] == 0 and not identity["dirty"]
280 if identity["git_short_commit"]:
281 development = "" if identity["dev_distance"] in (0, None) else f".dev{identity['dev_distance']}"
282 suffix = f"{development}+g{identity['git_short_commit']}"
283 if identity["dirty"]:
284 suffix += ".dirty"
285 identity["build_id"] = release_version + suffix
286 identity["version"] = identity["build_id"]
287 return identity
288
289
290@functools.lru_cache(maxsize=None)
291def picurv_build() -> dict:
292 """!
293 @brief The conductor's own build identity, resolved on first use and kept for the process.
294 @details Commands that only parse, validate, or scaffold never touch git; manifests,
295 locks, and `picurv version` do, once. Callers that store it copy it.
296 @return The cached identity mapping from _source_build_identity().
297 """
298 return _source_build_identity(PICURV_RELEASE_VERSION)
299
300
301PICURV_RELEASE_VERSION = _read_release_version()
302CASE_ORIGIN_METADATA_FILENAME = ".picurv-origin.json"
303WORKSPACE_CONFIG_FILENAME = ".picurv-workspace.yml"
304WORKSPACE_SCHEMA_VERSION = 1
305RUN_MANIFEST_SCHEMA_VERSION = 3
306ASSET_MANIFEST_SCHEMA_VERSION = 1
307ASSET_LOCK_SCHEMA_VERSION = 1
308RUNTIME_EXECUTION_CONFIG_FILENAME = ".picurv-execution.yml"
309LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME = ".picurv-local.yml"
310RUNTIME_EXECUTION_EXAMPLE_FILENAME = "execution.example.yml"
311RUNTIME_EXECUTION_CONFIG_FILENAMES = (
312 RUNTIME_EXECUTION_CONFIG_FILENAME,
313 LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME,
314)
315
316DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE = """# Optional shared runtime launcher overrides.
317# This file is safe to leave unchanged on ordinary local machines.
318# Edit it only when your site needs custom MPI launcher tokens.
319#
320# Precedence:
321# - local/login-node runs: local_execution -> default_execution -> built-in mpiexec
322# - generated cluster jobs: cluster.yml.execution -> cluster_execution -> default_execution -> built-in srun
323#
324# Example override:
325# default_execution:
326# launcher: "mpirun"
327# launcher_args:
328# - --bind-to
329# - none
330default_execution: {}
331
332local_execution: {}
333
334cluster_execution: {}
335"""
336
337CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT = "my_project_account"
338CLUSTER_TEMPLATE_PLACEHOLDER_MAIL = "user@example.edu"
339
340DEFAULT_WALLTIME_GUARD_POLICY = {
341 "enabled": True,
342 "warmup_steps": 10,
343 "multiplier": 2.0,
344 "min_seconds": 60.0,
345 "estimator_alpha": 0.35,
346}
347WALLTIME_GUARD_ENV_JOB_START_EPOCH = "PICURV_JOB_START_EPOCH"
348WALLTIME_GUARD_ENV_LIMIT_SECONDS = "PICURV_WALLTIME_LIMIT_SECONDS"
349POST_RESUME_STATE_FILENAME = "post.resume.json"
350POST_LOCK_FILENAME = "post.lock"
351POST_LOCK_METADATA_FILENAME = "post.lock.json"
352POST_LOCK_WRAPPER_FILENAME = "post_lock_wrapper.py"
353POST_RESUME_SCHEMA_VERSION = 2
354# Keys that choose which steps are processed or where they are read from, not what a
355# step's output contains; a step's record stays valid across a change to any of them.
356POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {"startTime", "endTime", "timeStep", "source_directory"}
357CHECKPOINT_FORMAT = "picurv-checkpoint"
358CHECKPOINT_VERSION = 1
359CHECKPOINT_STEP_WIDTH = 12
360CHECKPOINT_REQUIRED_EULERIAN_FIELDS = {"Ucat", "Ucont", "Ucont_rm1", "P", "Nvert"}
361
362WORKSPACE_DIRECTORY_LAYOUT = (
363 "config",
364 "config/studies",
365 "config/grids",
366 "config/initial_conditions",
367 "config/inlet_profiles",
368 "inputs",
369 "inputs/grids",
370 "inputs/initial_conditions",
371 "inputs/inlet_profiles",
372 "inputs/reference_fields",
373 "assets",
374 "assets/objects",
375 "assets/objects/grids",
376 "assets/objects/initial_conditions",
377 "assets/objects/inlet_profiles",
378 "assets/sets",
379 "runs",
380 "studies",
381)
382
383RUN_DIRECTORY_LAYOUT = (
384 "config",
385 "config/history",
386 "config/post-recipes",
387 "inputs",
388 "inputs/grid",
389 "inputs/initial_condition",
390 "inputs/inlet_profiles",
391 "inputs/restart",
392 "output",
393 "output/checkpoints",
394 "output/analysis",
395 "output/analysis/metrics",
396 "output/analysis/statistics",
397 "output/analysis/spectra",
398 "output/analysis/plots",
399 "output/visualization",
400 "logs",
401 "scheduler",
402)
403
404CANONICAL_RUN_PATHS = {
405 "config": "config",
406 "inputs": "inputs",
407 "restart": "inputs/restart",
408 "output": "output",
409 "checkpoints": "output/checkpoints",
410 "analysis": "output/analysis",
411 "metrics": "output/analysis/metrics",
412 "statistics": "output/analysis/statistics",
413 "spectra": "output/analysis/spectra",
414 "plots": "output/analysis/plots",
415 "visualization": "output/visualization",
416 "logs": "logs",
417 "scheduler": "scheduler",
418}
419
420#: Run-relative path of the staged initial-condition spectrum. It sits beside the
421#: post-produced spectra but is a reference overlay, not a selectable spectrum task.
422INITIAL_CONDITION_SPECTRUM_RELPATH = os.path.join(
423 CANONICAL_RUN_PATHS["spectra"], "initial_condition_spectrum.csv"
424)
425
426_WORKSPACE_ARTIFACT_ROOT_VALUES = {"runs", "studies"}
427_ASSET_SOURCE_REFERENCE_KEYS = {
428 "source_file", "path", "config_file", "field_file", "grid_file", "source_case", "script"
429}
430_PYTHON_INITIAL_CONDITION_PROVIDERS = {"ic_gen", "spectral_random_velocity", "resampled_velocity",
431 "channel_spectral_velocity", "duct_spectral_velocity",
432 "stability_mode_velocity"}
433#: Providers `ic.gen` runs from structured parameters, each with a measured initial spectrum.
434SPECTRAL_IC_PROVIDERS = ("spectral_random_velocity", "resampled_velocity",
435 "channel_spectral_velocity", "duct_spectral_velocity", "stability_mode_velocity")
436#: Of those, the triply periodic ones, whose initial spectrum is shell-averaged.
437SHELL_SPECTRUM_IC_PROVIDERS = ("spectral_random_velocity", "resampled_velocity")
438#: Parameters `spectral_random_velocity` accepts.
439SPECTRAL_RANDOM_VELOCITY_PARAMS = frozenset(
440 {"field", "seed", "random", "spectrum", "projection", "normalization", "remove_mean"}
441)
442#: Parameters `resampled_velocity` accepts.
443RESAMPLED_VELOCITY_PARAMS = frozenset(
444 {"field", "source_file", "format", "cells", "box_length", "sample_offset", "layout", "filter",
445 "projection", "remove_mean"}
446)
447#: `resampled_velocity` layout and projection choices.
448RESAMPLED_VELOCITY_FASTEST_AXES = ("x", "z")
449RESAMPLED_VELOCITY_COMPONENT_LAYOUTS = ("interleaved", "separate")
450RESAMPLED_VELOCITY_RAW_DTYPES = ("float32", "float64")
451RESAMPLED_VELOCITY_BYTE_ORDERS = ("little", "big", "native")
452RESAMPLED_VELOCITY_PROJECTIONS = ("picurv_discrete", "continuum", "none")
453#: Source containers `resampled_velocity` reads, and the layout keys each accepts.
454RESAMPLED_VELOCITY_LAYOUT_KEYS = {
455 "raw": {"fastest_axis", "components", "dtype", "byte_order", "header_bytes"},
456 "npy": {"fastest_axis", "components"},
457 "npz": {"fastest_axis", "components", "datasets"},
458 "hdf5": {"fastest_axis", "components", "datasets"},
459}
460_FILE_BACKED_GRID_VALUES = {"file", "grid_gen"}
461_WORKSPACE_MANAGED_PATHS = {"assets", "inputs", "runs", "studies"}
462_VENDORABLE_CONFIG_REFERENCE_KEYS = {"config_file", "script"}
463_PLAIN_FILENAME_SENTINELS = {"", ".", ".."}
464_VERSION_BUILD_ACTIONS = {"install", "activate"}
465
466
467def _find_named_file_upwards(start: str, filename: str):
468 """!
469 @brief Find a named file at or above an arbitrary filesystem anchor.
470 @param[in] start File or directory from which to search.
471 @param[in] filename Basename to locate.
472 @return Absolute path when found, otherwise None.
473 """
474 current = os.path.abspath(start or os.getcwd())
475 if os.path.isfile(current):
476 current = os.path.dirname(current)
477 while True:
478 candidate = os.path.join(current, filename)
479 if os.path.isfile(candidate):
480 return candidate
481 parent = os.path.dirname(current)
482 if parent == current:
483 return None
484 current = parent
485
486
488 """!
489 @brief Locate the nearest initialized PICurv workspace for supplied anchors.
490 @param[in] anchors Candidate config, run, study, or current-working-directory paths.
491 @return Workspace root path when found, otherwise None.
492 """
493 for anchor in anchors or (os.getcwd(),):
494 if not anchor:
495 continue
496 found = _find_named_file_upwards(str(anchor), WORKSPACE_CONFIG_FILENAME)
497 if found:
498 return os.path.dirname(found)
499 return None
500
501
502def load_workspace_config(workspace_root: str) -> dict:
503 """!
504 @brief Load and validate the immutable workspace identity/configuration file.
505 @param[in] workspace_root Initialized workspace directory.
506 @return Parsed workspace configuration mapping.
507 """
508 path = os.path.join(os.path.abspath(workspace_root), WORKSPACE_CONFIG_FILENAME)
509 payload = read_yaml_file(path)
510 if payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION:
511 raise ValueError(
512 f"{path}: unsupported workspace schema_version "
513 f"{payload.get('schema_version')!r}; expected {WORKSPACE_SCHEMA_VERSION}."
514 )
515 errors: list = []
516 _validate_yaml_schema_keys(payload, _WORKSPACE_SCHEMA, path, errors)
517 if errors:
518 raise ValueError("\n".join(errors))
519 return payload
520
521
522def enforce_workspace_version(workspace_root: str) -> dict:
523 """!
524 @brief Enforce an optional workspace PICurv version requirement.
525 @param[in] workspace_root Initialized workspace directory.
526 @return Current build identity after successful validation.
527 """
528 payload = load_workspace_config(workspace_root)
529 software = payload.get("software") or {}
530 requirement = software.get("picurv") if isinstance(software, dict) else None
531 if requirement in (None, ""):
532 return dict(picurv_build())
533 try:
534 from packaging.specifiers import SpecifierSet
535 from packaging.version import Version
536 requirement_text = str(requirement).strip()
537 if not any(token in requirement_text for token in "<>=!~"):
538 requirement_text = "==" + requirement_text
539 matches = Version(PICURV_RELEASE_VERSION) in SpecifierSet(requirement_text)
540 except Exception as exc:
541 raise ValueError(
542 f"{WORKSPACE_CONFIG_FILENAME}: software.picurv={requirement!r} is not a valid "
543 "version or version range."
544 ) from exc
545 if not matches:
546 raise ValueError(
547 f"Workspace requires PICurv {requirement!r}, but the active release is "
548 f"{PICURV_RELEASE_VERSION} (build {picurv_build()['build_id']}).\n"
549 f"Activate a matching installation with 'picurv versions activate <version>' "
550 "and retry.\n"
551 f"Note: {PACKAGE_PROJECT_ROOT} is a single shared installation. Activating "
552 "re-points every workspace and every unpinned job that resolves executables "
553 "from it, so two workspaces pinned to different releases cannot both be "
554 "satisfied at once. A run staged with --pin-executables keeps its own copy and "
555 "survives an activation."
556 )
557 return dict(picurv_build())
558
559
560def enforce_reproducibility_policy(workspace_root: str) -> dict:
561 """!
562 @brief Enforce an optional workspace policy demanding a clean, released build.
563
564 @details Exploratory work should stay frictionless, so this is opt-in: a workspace
565 running a campaign that will be published sets it once, and PICurv then
566 refuses to stage from a modified or untagged tree instead of recording the
567 compromise in a manifest nobody reads until later.
568 @param[in] workspace_root Initialized workspace directory, or None.
569 @return The resolved policy mapping, empty when none is configured.
570 @throws ValueError when the active build does not satisfy the configured policy.
571 """
572 if not workspace_root:
573 return {}
574 payload = load_workspace_config(workspace_root)
575 policy = payload.get("reproducibility") or {}
576 if not isinstance(policy, dict) or not policy:
577 return {}
578 if policy.get("require_clean_release"):
579 problems = []
580 if picurv_build().get("dirty"):
581 problems.append("the source tree has uncommitted changes")
582 if picurv_build().get("dev_distance"):
583 problems.append(
584 f"HEAD is {picurv_build()['dev_distance']} commit(s) past the "
585 f"v{PICURV_RELEASE_VERSION} release tag"
586 )
587 elif picurv_build().get("dev_distance") is None:
588 problems.append("the release tag could not be resolved from this checkout")
589 if problems:
590 raise ValueError(
591 f"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.require_clean_release, "
592 f"but this build is {picurv_build()['build_id']}: " + "; ".join(problems) + ".\n"
593 "Commit and tag the release, or clear the policy for exploratory work."
594 )
595 if policy.get("pin_executables"):
596 identities = runtime_build_identities()
597 stale = [
598 name for name, identity in sorted(identities.items())
599 if identity.get("available") and not identity.get("matches_source")
600 ]
601 unavailable = [
602 name for name, identity in sorted(identities.items())
603 if not identity.get("available")
604 ]
605 if stale or unavailable:
606 detail = []
607 if stale:
608 detail.append("built from another revision: " + ", ".join(stale))
609 if unavailable:
610 detail.append("no build identity available: " + ", ".join(unavailable))
611 commands = sorted({rebuild_command(name, identities[name]) for name in stale + unavailable})
612 raise ValueError(
613 f"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.pin_executables, but "
614 "the executables do not match the active source (" + "; ".join(detail) + ").\n"
615 "Rebuild with " + " and ".join(f"'{command}'" for command in commands)
616 + " so the run records the build that produced it."
617 )
618 return dict(policy)
619
620
621def resolve_workspace_path(anchor_file: str, candidate: str, *, allow_external: bool = False) -> str:
622 """!
623 @brief Resolve a user path against its workspace and reject implicit escapes.
624 @param[in] anchor_file Config file whose workspace owns the reference.
625 @param[in] candidate Workspace-relative path text.
626 @param[in] allow_external Whether an explicit import/reference operation permits an absolute path.
627 @return Absolute resolved path.
628 """
629 if not isinstance(candidate, str) or not candidate.strip():
630 raise ValueError("Referenced path must be a non-empty string.")
631 text = os.path.expanduser(candidate.strip())
632 workspace_root = find_workspace_root(anchor_file)
633 if not workspace_root:
634 return os.path.abspath(text if os.path.isabs(text) else os.path.join(os.path.dirname(os.path.abspath(anchor_file)), text))
635 if os.path.isabs(text):
636 if allow_external:
637 return os.path.abspath(text)
638 raise ValueError(
639 f"{anchor_file}: absolute path {candidate!r} is not allowed in workspace configuration; "
640 "import it with 'picurv inputs import' or use an explicit reference-mode import."
641 )
642 resolved = os.path.abspath(os.path.join(workspace_root, text))
643 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
644 raise ValueError(
645 f"{anchor_file}: path {candidate!r} escapes the workspace; parent traversal is not allowed."
646 )
647 if resolved.endswith(".reference.yml") and os.path.isfile(resolved):
648 pointer = read_yaml_file(resolved)
649 external = pointer.get("picurv_external_reference") if isinstance(pointer, dict) else None
650 if not isinstance(external, str) or not os.path.isabs(external):
651 raise ValueError(f"Invalid external-reference descriptor: {resolved}")
652 if not os.path.isfile(external):
653 raise ValueError(f"Registered external input is unavailable: {external}")
654 return external
655 return resolved
656
657
658def ensure_workspace_layout(workspace_root: str) -> None:
659 """!
660 @brief Materialize the uniform, cheap directory skeleton for one workspace.
661 @param[in] workspace_root Workspace root to initialize.
662 """
663 for relative in WORKSPACE_DIRECTORY_LAYOUT:
664 os.makedirs(os.path.join(workspace_root, *relative.split("/")), exist_ok=True)
665
666
667def initialize_workspace_root(workspace_root: str, template_name: str) -> str:
668 """!
669 @brief Create the workspace skeleton and its identity file at one root.
670 @param[in] workspace_root Workspace root to initialize.
671 @param[in] template_name Example template the workspace was created from.
672 @return Path to the written workspace configuration file.
673 """
674 workspace_root = os.path.abspath(workspace_root)
675 ensure_workspace_layout(workspace_root)
676 config_path = os.path.join(workspace_root, WORKSPACE_CONFIG_FILENAME)
677 write_yaml_file(config_path, {
678 "schema_version": WORKSPACE_SCHEMA_VERSION,
679 "workspace": {
680 "id": os.path.basename(workspace_root),
681 "template": template_name,
682 "created_at": datetime.now().astimezone().isoformat(),
683 },
684 "software": {},
685 "paths": {
686 "config": "config",
687 "inputs": "inputs",
688 "assets": "assets",
689 "runs": "runs",
690 "studies": "studies",
691 },
692 })
693 return config_path
694
695
696def ensure_run_layout(run_dir: str) -> None:
697 """!
698 @brief Materialize the uniform, cheap directory skeleton for one run.
699 @param[in] run_dir Run root to initialize.
700 """
701 for relative in RUN_DIRECTORY_LAYOUT:
702 os.makedirs(os.path.join(run_dir, *relative.split("/")), exist_ok=True)
703
704
705#: Top-level directory names a run owns. Anything else beside them is a peer PICurv
706#: does not route, cannot classify for storage, and will not prune.
707RUN_DIRECTORY_ROOTS = frozenset(
708 relative.split("/")[0] for relative in RUN_DIRECTORY_LAYOUT
709)
710
711#: Files a run legitimately carries at its own root. Storage's own markers are added
712#: at validation time, so this stays the list of what the conductor writes.
713RUN_ROOT_ALLOWED_FILES = frozenset({"manifest.json"})
714
715
716def validate_run_directory_structure(run_dir: str) -> tuple:
717 """!
718 @brief Refuse a run whose root has grown a directory the layout does not define.
719
720 @details The run topology is a contract: `manifest.json` publishes it, the solver's
721 reserved-directory guard defends it, and storage classifies against it. A
722 directory beside `output/` breaks all three at once - it is routed by
723 nothing, classified as `unclassified`, and therefore archived forever and
724 pruned never. Catching it when the run is staged or resumed is the only
725 point where the answer is still "move it", rather than "it is already in
726 every archive of this run".
727
728 Unexpected *files* are reported and allowed. A stray note at a run root
729 costs nothing and refusing to resume a long campaign over one would be a
730 worse failure than the one being prevented.
731 @param[in] run_dir Run root to check.
732 @return Tuple of (errors, warnings) as human-readable message lists.
733 """
734 root = os.path.abspath(run_dir)
735 if not os.path.isdir(root):
736 return [], []
737 allowed_files = set(RUN_ROOT_ALLOWED_FILES) | {
738 STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME,
739 }
740 errors, warnings = [], []
741 for name in sorted(os.listdir(root)):
742 path = os.path.join(root, name)
743 if os.path.isdir(path) and not os.path.islink(path):
744 if name not in RUN_DIRECTORY_ROOTS:
745 errors.append(
746 f"{run_dir}: '{name}/' is not part of the run layout. Scientific "
747 f"output belongs under {CANONICAL_RUN_PATHS['checkpoints']}, "
748 f"{CANONICAL_RUN_PATHS['analysis']}, or "
749 f"{CANONICAL_RUN_PATHS['visualization']}; move or remove '{name}/' "
750 f"before running. Run-owned roots are: "
751 + ", ".join(sorted(RUN_DIRECTORY_ROOTS)) + "."
752 )
753 elif name not in allowed_files:
754 warnings.append(
755 f"{run_dir}: unexpected file '{name}' at the run root. It is archived "
756 "as unclassified and never pruned."
757 )
758 return errors, warnings
759
760
761def enforce_run_directory_structure(run_dir: str) -> None:
762 """!
763 @brief Apply `validate_run_directory_structure()` as a refusal at run time.
764 @param[in] run_dir Run root to check.
765 @return None. Exits non-zero when the run root carries a directory outside the layout.
766 """
767 errors, warnings = validate_run_directory_structure(run_dir)
768 for message in warnings:
769 print(f"[WARNING] {message}", file=sys.stderr)
770 if not errors:
771 return
772 for message in errors:
774 ERROR_CODE_CFG_INCONSISTENT_COMBO,
775 key="run_layout",
776 file_path=os.path.abspath(run_dir),
777 message=message,
778 hint="Move the directory under output/ or out of the run, then retry.",
779 )
780 sys.exit(1)
781
782
783def case_run_label(case_cfg: dict, case_path: str) -> str:
784 """!
785 @brief Resolve the stable human-facing portion of a generated run identifier.
786 @param[in] case_cfg Parsed case configuration.
787 @param[in] case_path Source case YAML path.
788 @return Filesystem-safe case title, name, or source stem.
789 """
790 metadata = case_cfg.get("metadata") or {}
791 candidate = (
792 case_cfg.get("title")
793 or (metadata.get("title") if isinstance(metadata, dict) else None)
794 or (metadata.get("name") if isinstance(metadata, dict) else None)
795 or Path(case_path).stem
796 )
797 label = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(candidate).strip()).strip("-.")
798 return label or "case"
799
800
801def allocate_generated_run_id(runs_root: str, case_cfg: dict, case_path: str) -> str:
802 """!
803 @brief Build the generated run identity, disambiguating a same-second collision.
804
805 @details The identity is `<run label>_<timestamp>` at one-second resolution, so two
806 runs of the same case launched back to back - a script, or a smoke
807 sequence - would otherwise name the same directory. Creating a run never
808 writes into an existing one, so the identity is advanced instead of the
809 launch being refused for a clock artifact.
810 @param[in] runs_root Directory generated runs are created beneath.
811 @param[in] case_cfg Parsed case configuration supplying the run label.
812 @param[in] case_path Case path, for the label's fallback.
813 @return Run identity that does not currently exist under `runs_root`.
814 """
815 base = f"{case_run_label(case_cfg, case_path)}_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
816 run_id, suffix = base, 1
817 while os.path.exists(os.path.join(runs_root, run_id)):
818 suffix += 1
819 run_id = f"{base}-{suffix}"
820 return run_id
821
822
823def discard_unused_run_directory(run_dir: str, *, created: bool, staged: bool = False) -> bool:
824 """!
825 @brief Remove a generated run directory that never received any content.
826
827 @details Only a directory this invocation created, and which still holds no files,
828 is removed: an existing run, or one that already produced output, is never
829 touched by a staging failure. With @p staged the directory may hold the files
830 this invocation staged, for a refusal after staging but before any launch.
831 @param[in] run_dir Run directory that was about to be staged.
832 @param[in] created Whether this invocation created the directory.
833 @param[in] staged Whether files this invocation staged, and nothing else, may be present.
834 @return True when the directory was removed.
835 """
836 if not created or not os.path.isdir(run_dir):
837 return False
838 for _root, _dirs, files in os.walk(run_dir):
839 if files and not staged:
840 return False
841 shutil.rmtree(run_dir, ignore_errors=True)
842 return True
843
844
845def _file_sha256(path: str):
846 """!
847 @brief Content digest of one file, or None when it cannot be read.
848 @param[in] path File to digest.
849 @return Hex digest, or None.
850 """
851 digest = hashlib.sha256()
852 try:
853 with open(path, "rb") as stream:
854 for block in iter(lambda: stream.read(1024 * 1024), b""):
855 digest.update(block)
856 except OSError:
857 return None
858 return digest.hexdigest()
859
860
861def build_software_lock(run_dir: "str | None" = None) -> dict:
862 """!
863 @brief Capture the exact software identity a run is about to execute with.
864
865 @details The release and commit say which source is checked out; they do not say
866 which bytes ran. Hashing the executables and the generators pins that, so
867 a queued job rebuilt out from under it is detectable afterwards rather
868 than merely suspected.
869 @param[in] run_dir Run whose pinned executables are hashed; the installation's when None.
870 @return Software lock mapping.
871 """
872 lock = {
873 "schema_version": 1,
874 "picurv_version": PICURV_RELEASE_VERSION,
875 "build_id": picurv_build().get("build_id"),
876 "git_commit": picurv_build().get("git_commit"),
877 "git_dirty": picurv_build().get("dirty"),
878 "captured_at": datetime.now().astimezone().isoformat(),
879 "executables": {},
880 "generators": {},
881 "environment": _toolchain_identity(),
882 }
883 for name in RUN_EXECUTABLE_NAMES:
884 path = run_executable_path(run_dir, name)
885 identity = read_binary_build_identity(path)
886 lock["executables"][name] = {
887 "path": path,
888 "sha256": _file_sha256(path),
889 "build_id": identity.get("build_id"),
890 "matches_source": identity.get("matches_source"),
891 "petsc": identity.get("petsc"),
892 }
893 conductor = os.path.join(PACKAGE_PROJECT_ROOT, "picurv_cli", "core.py")
894 lock["python_conductor_sha256"] = _file_sha256(conductor)
895 if os.path.isdir(GENERATORS_PATH):
896 for entry in sorted(os.listdir(GENERATORS_PATH)):
897 candidate = os.path.join(GENERATORS_PATH, entry)
898 if os.path.isfile(candidate):
899 lock["generators"][entry] = _file_sha256(candidate)
900 return lock
901
902
904 """!
905 @brief Best-effort record of the PETSc, MPI, and compiler in the shell staging the run.
906 @details This is the staging environment, which need not be what the binaries were
907 built against; each executable's own PETSc is recorded from its build stamp.
908 @return Mapping of what could be determined; absent keys mean it could not be read.
909 """
910 identity = {}
911 petsc_dir = os.environ.get("PETSC_DIR")
912 if petsc_dir:
913 identity["petsc_dir"] = petsc_dir
914 version_header = os.path.join(petsc_dir, "include", "petscversion.h")
915 try:
916 with open(version_header, "r", encoding="utf-8", errors="replace") as stream:
917 numbers = {}
918 for line in stream:
919 match = re.match(
920 r"#define\s+PETSC_VERSION_(MAJOR|MINOR|SUBMINOR)\s+(\d+)", line
921 )
922 if match:
923 numbers[match.group(1)] = match.group(2)
924 if len(numbers) == 3:
925 identity["petsc_version"] = (
926 f"{numbers['MAJOR']}.{numbers['MINOR']}.{numbers['SUBMINOR']}"
927 )
928 except OSError:
929 pass
930 if os.environ.get("PETSC_ARCH"):
931 identity["petsc_arch"] = os.environ["PETSC_ARCH"]
932 for tool, key in (("mpiexec", "mpi"), ("mpicc", "compiler")):
933 executable = shutil.which(tool)
934 if not executable:
935 continue
936 try:
937 result = subprocess.run(
938 [executable, "--version"], text=True, capture_output=True, timeout=20, check=False
939 )
940 except (OSError, subprocess.SubprocessError):
941 continue
942 first = (result.stdout or result.stderr or "").strip().splitlines()
943 if first:
944 identity[key] = first[0][:200]
945 return identity
946
947
948def write_software_lock(run_dir: str) -> str:
949 """!
950 @brief Write the run's software lock beside its asset lock.
951 @param[in] run_dir Run directory receiving the lock.
952 @return Path to the written lock.
953 """
954 path = os.path.join(run_dir, CANONICAL_RUN_PATHS["inputs"], "software.lock.json")
955 os.makedirs(os.path.dirname(path), exist_ok=True)
957 return path
958
959
960def workspace_artifact_root(workspace_root: str, kind: str) -> str:
961 """!
962 @brief Return the canonical workspace-owned root for runs or studies.
963 @param[in] workspace_root Initialized workspace, or None for standalone mode.
964 @param[in] kind Either runs or studies.
965 @return Absolute artifact root.
966 """
967 if kind not in _WORKSPACE_ARTIFACT_ROOT_VALUES:
968 raise ValueError(f"Unsupported workspace artifact kind: {kind}")
969 if workspace_root:
970 return os.path.join(os.path.abspath(workspace_root), kind)
971 return os.path.abspath(kind)
972
973
974def _relative_to_workspace(path: str, workspace_root: str):
975 """!
976 @brief Return a portable workspace-relative path when possible.
977 @param[in] path Path to normalize.
978 @param[in] workspace_root Optional initialized workspace root.
979 @return Workspace-relative POSIX path, absolute path, or None.
980 """
981 if not path:
982 return None
983 absolute = os.path.abspath(path)
984 if workspace_root and os.path.commonpath([absolute, workspace_root]) == os.path.abspath(workspace_root):
985 return os.path.relpath(absolute, workspace_root).replace(os.sep, "/")
986 return absolute
987
988
989def snapshot_run_configuration(run_dir: str, source_paths: dict,
990 continuation: bool = False) -> dict:
991 """!
992 @brief Snapshot editable YAML inputs without erasing prior continuation state.
993 @param[in] run_dir Owning run directory.
994 @param[in] source_paths Role-to-source-path mapping.
995 @param[in] continuation Whether this is a continuation configuration revision.
996 @return Active snapshot metadata.
997 """
998 config_root = os.path.join(run_dir, "config")
999 os.makedirs(config_root, exist_ok=True)
1000 previous = _read_json_if_exists(os.path.join(config_root, "active.json")) or {}
1001 revision = None
1002 destination_root = config_root
1003 if continuation:
1004 revision = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
1005 destination_root = os.path.join(config_root, "history", revision)
1006 os.makedirs(destination_root, exist_ok=False)
1007 snapshots = {}
1008 for role, source in source_paths.items():
1009 if not source:
1010 continue
1011 source = os.path.abspath(source)
1012 suffix = os.path.splitext(source)[1] or ".yml"
1013 name = f"{role}{suffix}" if role != "cluster" else "cluster.yml"
1014 destination = os.path.join(destination_root, name)
1015 shutil.copy2(source, destination)
1016 snapshots[role] = os.path.relpath(destination, run_dir).replace(os.sep, "/")
1017 active = {
1018 "schema_version": 1,
1019 "revision": revision or "initial",
1020 "updated_at": datetime.now().astimezone().isoformat(),
1021 "files": snapshots,
1022 }
1023 if continuation and isinstance(previous.get("executables"), dict):
1024 # A continuation changes what is configured, not which build runs it. The pin
1025 # moves only when the caller re-pins, which records a new executables entry.
1026 active["executables"] = previous["executables"]
1027 write_json_file(os.path.join(config_root, "active.json"), active)
1028 return active
1029
1030
1031#: Run records a continuation's staging rewrites in place, relative to the run directory.
1032CONTINUATION_STAGED_RECORDS = ("config/active.json", "manifest.json",
1033 "inputs/assets.lock.yml", "inputs/software.lock.json")
1034
1035
1036def capture_continuation_records(run_dir: str, restart_dir: str) -> dict:
1037 """!
1038 @brief Save what a continuation's staging changes, so a refusal can undo it.
1039 @details Staging rewrites the records in CONTINUATION_STAGED_RECORDS and materializes
1040 the restart checkpoint into the run's restart input, so both are captured
1041 before restart resolution runs.
1042 @param[in] run_dir Existing run directory.
1043 @param[in] restart_dir The run's restart input directory.
1044 @return Saved state for restore_continuation_records().
1045 """
1046 records = {}
1047 for relative in CONTINUATION_STAGED_RECORDS:
1048 path = os.path.join(run_dir, *relative.split("/"))
1049 records[relative] = open(path, "rb").read() if os.path.isfile(path) else None
1050 checkpoints = os.path.join(restart_dir, "checkpoints")
1051 return {"records": records, "checkpoints": checkpoints,
1052 "checkpoint_entries": set(os.listdir(checkpoints)) if os.path.isdir(checkpoints) else None}
1053
1054
1055def restore_continuation_records(run_dir: str, saved: dict, revision: str):
1056 """!
1057 @brief Undo a refused continuation's staging: restore its records, drop its revision.
1058 @details Removes the restart checkpoints staging materialized; everything else a
1059 continuation stages, including pinned executables, lives in the revision
1060 directory, so removing it leaves the run as it was.
1061 @param[in] run_dir Existing run directory.
1062 @param[in] saved State returned by capture_continuation_records().
1063 @param[in] revision Configuration revision the refused staging created.
1064 """
1065 checkpoints, before = saved["checkpoints"], saved["checkpoint_entries"]
1066 if os.path.isdir(checkpoints):
1067 for entry in set(os.listdir(checkpoints)) - (before or set()):
1068 shutil.rmtree(os.path.join(checkpoints, entry), ignore_errors=True)
1069 if before is None and not os.listdir(checkpoints):
1070 os.rmdir(checkpoints)
1071 for relative, content in saved["records"].items():
1072 path = os.path.join(run_dir, *relative.split("/"))
1073 if content is None:
1074 if os.path.isfile(path):
1075 os.remove(path)
1076 else:
1077 with open(path, "wb") as stream:
1078 stream.write(content)
1079 shutil.rmtree(os.path.join(run_dir, "config", "history", revision), ignore_errors=True)
1080
1081
1082def load_active_run_configuration(run_dir: str) -> dict:
1083 """!
1084 @brief Load the active immutable configuration revision for a run.
1085 @param[in] run_dir Run directory.
1086 @return Role-to-absolute-path mapping.
1087 """
1088 config_root = os.path.join(os.path.abspath(run_dir), "config")
1089 active = _read_json_if_exists(os.path.join(config_root, "active.json"))
1090 if isinstance(active, dict) and isinstance(active.get("files"), dict):
1091 return {
1092 role: os.path.join(run_dir, *relative.split("/"))
1093 for role, relative in active["files"].items()
1094 if isinstance(relative, str)
1095 }
1096 return {
1097 role: os.path.join(config_root, f"{role}.yml")
1098 for role in ("case", "solver", "monitor", "cluster")
1099 if os.path.isfile(os.path.join(config_root, f"{role}.yml"))
1100 }
1101
1102
1103#: Native executables a run launches, and therefore the ones a run pins.
1104RUN_EXECUTABLE_NAMES = ("simulator", "postprocessor")
1105
1106
1107def load_run_executables(run_dir: str) -> dict:
1108 """!
1109 @brief Return the executables a run is pinned to, as recorded by its active configuration.
1110 @param[in] run_dir Run directory.
1111 @return Mapping of executable name to its recorded entry, with `path` made absolute;
1112 empty when the run launches the installation's executables.
1113 """
1114 active = _read_json_if_exists(os.path.join(os.path.abspath(run_dir), "config", "active.json"))
1115 recorded = active.get("executables") if isinstance(active, dict) else None
1116 if not isinstance(recorded, dict):
1117 return {}
1118 pinned = {}
1119 for name, entry in recorded.items():
1120 if isinstance(entry, dict) and isinstance(entry.get("path"), str):
1121 pinned[name] = dict(entry, path=os.path.join(os.path.abspath(run_dir), *entry["path"].split("/")))
1122 return pinned
1123
1124
1125def run_executable_path(run_dir: "str | None", name: str) -> str:
1126 """!
1127 @brief Resolve the executable a run launches: its pinned copy, else the installation's.
1128 @param[in] run_dir Run directory, or None when no run exists yet.
1129 @param[in] name `simulator` or `postprocessor`.
1130 @return Absolute executable path.
1131 @throws ValueError when the run records a pinned copy that is no longer on disk.
1132 """
1133 pinned = load_run_executables(run_dir).get(name) if run_dir else None
1134 if not pinned:
1135 return resolve_runtime_executable(name)
1136 if not os.path.isfile(pinned["path"]):
1137 raise ValueError(
1138 f"{run_dir} is pinned to {pinned['path']}, which no longer exists. Restore it "
1139 "from the run's archive, or re-pin with --pin-executables to run the current build."
1140 )
1141 return pinned["path"]
1142
1143
1144def pin_run_executables(run_dir: str, revision: str) -> dict:
1145 """!
1146 @brief Copy the installation's executables into a run's configuration revision.
1147
1148 @details The installation's `bin/` is rebuilt in place, so a job that resolves its
1149 executable there when it starts runs whatever was built last, not what was
1150 staged. The copy lives under `config/`, which storage retains as essential
1151 and the solver refuses to write into, and `active.json` records it so every
1152 later stage of the run - continuation, post-processing - launches the same
1153 build. Each copy is written beside its destination and renamed into place.
1154 @param[in] run_dir Run directory.
1155 @param[in] revision Active configuration revision (`initial` or a continuation id).
1156 @return Recorded executables mapping, keyed by executable name; an executable that
1157 has not been built is left unpinned with a warning, as staging treats it today.
1158 """
1159 run_root = os.path.abspath(run_dir)
1160 config_root = os.path.join(run_root, "config")
1161 target = (os.path.join(config_root, "bin") if revision == "initial"
1162 else os.path.join(config_root, "history", revision, "bin"))
1163 recorded = {}
1164 for name in RUN_EXECUTABLE_NAMES:
1165 source = resolve_runtime_executable(name)
1166 if not os.path.isfile(source):
1167 print(f"[WARNING] {name} is not built at {source}, so it is not pinned; a stage that "
1168 "launches it will use whatever is there when it starts.", file=sys.stderr)
1169 continue
1170 os.makedirs(target, exist_ok=True)
1171 destination = os.path.join(target, name)
1172 partial = destination + ".partial"
1173 shutil.copy2(source, partial)
1174 os.replace(partial, destination)
1175 identity = read_binary_build_identity(destination)
1176 recorded[name] = {
1177 "path": os.path.relpath(destination, run_root).replace(os.sep, "/"),
1178 "pinned_from": source,
1179 "sha256": _file_sha256(destination),
1180 "build_id": identity.get("build_id"),
1181 "version_line": identity.get("version_line"),
1182 "identity_reason": None if identity.get("available") else identity.get("reason"),
1183 }
1184 if not recorded:
1185 return {}
1186 active_path = os.path.join(config_root, "active.json")
1187 active = _read_json_if_exists(active_path) or {"schema_version": 1, "revision": revision, "files": {}}
1188 active["executables"] = recorded
1189 active["updated_at"] = datetime.now().astimezone().isoformat()
1190 write_json_file(active_path, active)
1191 return recorded
1192
1193
1194def plan_run_executable_paths(run_dir: str, args, workspace_root: "str | None") -> dict:
1195 """!
1196 @brief Predict, without writing, which executables staging would give a run.
1197 @details Mirrors `apply_run_executable_pinning()` for `run --dry-run`: an existing
1198 pin is kept unless re-pinned, a new pin lands in the configuration revision
1199 staging would create, and otherwise the installation's executables run.
1200 @param[in] run_dir Run directory the plan targets.
1201 @param[in] args Parsed command arguments.
1202 @param[in] workspace_root Owning workspace, or None.
1203 @return Mapping with `pinning` (`kept`, `new`, or `none`) and per-executable `paths`.
1204 """
1205 existing = load_run_executables(run_dir) if os.path.isdir(run_dir) else {}
1206 if existing and getattr(args, "pin_executables", None) is not True:
1207 return {"pinning": "kept", "paths": {name: entry["path"] for name, entry in existing.items()}}
1208 if resolve_executable_pinning(args, workspace_root):
1209 revision_root = (os.path.join(run_dir, "config") if not existing
1210 and not getattr(args, "continue_run", False)
1211 else os.path.join(run_dir, "config", "history", "<new-revision>"))
1212 return {"pinning": "new",
1213 "paths": {name: os.path.join(revision_root, "bin", name) for name in RUN_EXECUTABLE_NAMES}}
1214 return {"pinning": "none",
1215 "paths": {name: resolve_runtime_executable(name) for name in RUN_EXECUTABLE_NAMES}}
1216
1217
1218def resolve_executable_pinning(args, workspace_root: "str | None") -> bool:
1219 """!
1220 @brief Decide whether staging pins the installation's executables into the run.
1221 @details Pinning is opt-in: an explicit `--pin-executables`/`--no-pin-executables`
1222 wins, then the workspace's `reproducibility.pin_executables`. Unpinned jobs
1223 are still protected by the job-start identity check, which stops a job whose
1224 executable was rebuilt after staging instead of running the new build.
1225 @param[in] args Parsed command arguments.
1226 @param[in] workspace_root Owning workspace, or None.
1227 @return True to pin, False not to.
1228 """
1229 explicit = getattr(args, "pin_executables", None)
1230 if explicit is not None:
1231 return bool(explicit)
1232 if workspace_root:
1233 policy = load_workspace_config(workspace_root).get("reproducibility") or {}
1234 if isinstance(policy, dict):
1235 return bool(policy.get("pin_executables"))
1236 return False
1237
1238
1239def apply_run_executable_pinning(run_dir: str, revision: str, args,
1240 workspace_root: "str | None") -> dict:
1241 """!
1242 @brief Pin, keep, or refuse the run's executables before anything records them.
1243 @details A run already pinned keeps its build unless the caller explicitly re-pins,
1244 so a continuation or post-processing pass cannot silently switch builds.
1245 Refusing `--no-pin-executables` on a pinned run keeps the run's records
1246 describing one build rather than a pin nobody launched.
1247 @param[in] run_dir Run directory.
1248 @param[in] revision Active configuration revision.
1249 @param[in] args Parsed command arguments.
1250 @param[in] workspace_root Owning workspace, or None.
1251 @return The run's pinned executables; empty when it launches the installation's.
1252 @throws ValueError when `--no-pin-executables` is given for a pinned run.
1253 """
1254 explicit = getattr(args, "pin_executables", None)
1255 existing = load_run_executables(run_dir)
1256 if existing and explicit is not True:
1257 if explicit is False:
1258 build = existing.get("simulator", {}).get("build_id") or "a pinned build"
1259 raise ValueError(
1260 f"{run_dir} is pinned to {build}. --no-pin-executables would launch the "
1261 "installation's build instead while the run still records the pin; re-pin "
1262 "with --pin-executables to move this run to the current build."
1263 )
1264 build = existing.get("simulator", {}).get("build_id") or "unknown build"
1265 print(f"[INFO] Launching the run's pinned executables ({build}).")
1266 return existing
1267 if not resolve_executable_pinning(args, workspace_root):
1268 return {}
1269 recorded = pin_run_executables(run_dir, revision)
1270 if not recorded:
1271 return {}
1272 first = next(iter(recorded.values()))
1273 build = first.get("build_id") or "build identity unavailable"
1274 print(f"[INFO] Pinned {', '.join(sorted(recorded))} into "
1275 f"{os.path.dirname(first['path'])}/ ({build}).")
1276 return load_run_executables(run_dir)
1277
1278
1279def archive_active_generated_configuration(run_dir: str, paths: list) -> dict:
1280 """!
1281 @brief Preserve generated control sidecars beside a continuation's YAML revision.
1282 @param[in] run_dir Owning run.
1283 @param[in] paths Generated control and sidecar paths.
1284 @return Updated active configuration record.
1285 """
1286 config_root = os.path.join(os.path.abspath(run_dir), "config")
1287 active_path = os.path.join(config_root, "active.json")
1288 active = _read_json_if_exists(active_path) or {"schema_version": 1, "revision": "initial", "files": {}}
1289 revision = active.get("revision", "initial")
1290 target_root = config_root if revision == "initial" else os.path.join(config_root, "history", revision)
1291 os.makedirs(target_root, exist_ok=True)
1292 files = active.setdefault("files", {})
1293 for source in paths:
1294 if not source or not os.path.isfile(source):
1295 continue
1296 source = os.path.abspath(source)
1297 destination = os.path.join(target_root, os.path.basename(source))
1298 if source != destination:
1299 shutil.copy2(source, destination)
1300 relative = os.path.relpath(destination, run_dir).replace(os.sep, "/")
1301 if destination.endswith(".control"):
1302 files["control"] = relative
1303 else:
1304 files.setdefault("generated", []).append(relative)
1305 active["updated_at"] = datetime.now().astimezone().isoformat()
1306 write_json_file(active_path, active)
1307 return active
1308
1309
1310#: Storage states in which a run's heavy payload is no longer wholly local. A component
1311#: absent under one of these was archived, not skipped.
1312STORAGE_OFFLOADED_STATES = frozenset({"COLD", "PARTIAL"})
1313
1314#: Lifecycle states a run component can be in, as reported by the run manifest.
1315RUN_COMPONENT_STATES = (
1316 "not_requested", "planned", "running", "partial", "complete", "failed", "offloaded",
1317)
1318
1319#: Run components, their canonical home, and the retention class storage applies.
1320RUN_COMPONENT_LAYOUT = (
1321 ("configuration", "config", "essential"),
1322 ("inputs", "inputs", "essential"),
1323 ("checkpoints", "output/checkpoints", "policy"),
1324 ("analysis", "output/analysis", "derived"),
1325 ("field_statistics", "output/analysis/statistics", "derived"),
1326 ("spectra", "output/analysis/spectra", "derived"),
1327 ("visualization", "output/visualization", "derived"),
1328 ("logs", "logs", "essential"),
1329 ("scheduler", "scheduler", "essential"),
1330)
1331
1332
1333def _run_component_states(run_dir: str, stages_requested: dict) -> dict:
1334 """!
1335 @brief Report each run component's home, retention class, and lifecycle state.
1336
1337 @details The skeleton is created whole, so an empty directory says nothing about
1338 whether its contents were requested. This is where that question is
1339 answered, and it is answered for every component whether or not it ran.
1340 @param[in] run_dir Run directory being described.
1341 @param[in] stages_requested Mapping of requested stage names to booleans.
1342 @return Component name to `{path, retention, state}` mapping.
1343 """
1344 marker = storage_state_summary(run_dir) if os.path.isdir(run_dir) else {"state": "LOCAL"}
1345 offloaded = marker.get("state") in STORAGE_OFFLOADED_STATES
1346 solve_requested = bool(stages_requested.get("solve"))
1347 post_requested = bool(stages_requested.get("post_process"))
1348 requested = {
1349 "configuration": True,
1350 "inputs": True,
1351 "logs": True,
1352 "scheduler": True,
1353 "checkpoints": solve_requested,
1354 "analysis": post_requested,
1355 "field_statistics": post_requested,
1356 "spectra": post_requested,
1357 "visualization": post_requested,
1358 }
1359 components = {}
1360 for name, relative, retention in RUN_COMPONENT_LAYOUT:
1361 path = os.path.join(run_dir, *relative.split("/"))
1362 populated = False
1363 if os.path.isdir(path):
1364 populated = any(files for _root, _dirs, files in os.walk(path))
1365 if populated:
1366 state = "offloaded" if offloaded and retention != "essential" else "complete"
1367 elif not requested.get(name, False):
1368 state = "not_requested"
1369 elif offloaded:
1370 state = "offloaded"
1371 else:
1372 state = "planned"
1373 components[name] = {"path": relative, "retention": retention, "state": state}
1374 return components
1375
1376
1377def build_run_lineage(parent_run_dir: str, checkpoint_step: int, *, workspace_root=None,
1378 statistics_state=None, requested_source=None) -> dict:
1379 """!
1380 @brief Record which run and which checkpoint a branched run was started from.
1381
1382 @details Without this a branch is indistinguishable from a fresh run: the copied
1383 bundle under `inputs/restart/` carries the parent's geometry and software
1384 identity but never says which run produced it, so the trajectory a result
1385 belongs to cannot be reconstructed after the fact.
1386
1387 The parent's own manifest is asked for its identity rather than its
1388 directory name, so a parent that was renamed or restored under another
1389 name is still named correctly here.
1390 @param[in] parent_run_dir Absolute path to the run being branched from.
1391 @param[in] checkpoint_step Checkpoint step the branch starts from.
1392 @param[in] workspace_root Optional workspace root, to record a portable path.
1393 @param[in] statistics_state Resolved field-statistics decision for the branch.
1394 @param[in] requested_source What the user asked for, e.g. "latest" or a path.
1395 @return JSON-serializable lineage record.
1396 """
1397 parent_identity = read_artifact_identity(parent_run_dir)
1398 return {
1399 "relationship": "branch",
1400 "parent_run_id": parent_identity["run_id"],
1401 "parent_study_id": parent_identity["study_id"] if parent_identity["case_id"] else None,
1402 "parent_case_id": parent_identity["case_id"],
1403 "parent_identity_source": parent_identity["identity_source"],
1404 "parent_path": _relative_to_workspace(parent_run_dir, workspace_root),
1405 "checkpoint_step": int(checkpoint_step),
1406 "statistics_state": statistics_state,
1407 "requested_source": requested_source,
1408 "recorded_at": datetime.now().astimezone().isoformat(),
1409 }
1410
1411
1412def build_run_manifest(run_dir: str, run_id: str, *, workspace_root=None,
1413 launch_mode: str = "local", num_procs: int = 1,
1414 post_num_procs: int = 1, stages_requested=None,
1415 stages_completed=None, inputs=None, asset_lock=None,
1416 submission=None, lineage=None, artifact_type: str = "run",
1417 study_id=None, case_id=None) -> dict:
1418 """!
1419 @brief Build the authoritative run identity, topology, and lifecycle manifest.
1420 @param[in] run_dir Owning run directory.
1421 @param[in] run_id Stable run identity.
1422 @param[in] workspace_root Optional initialized workspace root.
1423 @param[in] launch_mode Local or scheduler-backed launch mode.
1424 @param[in] num_procs Effective solver process count.
1425 @param[in] post_num_procs Effective post-process count.
1426 @param[in] stages_requested Requested stage mapping.
1427 @param[in] stages_completed Completed or submitted stage names.
1428 @param[in] inputs Active configuration identities.
1429 @param[in] asset_lock Resolved run asset lock.
1430 @param[in] submission Scheduler submission metadata.
1431 @param[in] lineage Where this run's initial state came from; see `build_run_lineage()`.
1432 @param[in] artifact_type "run" for a standalone run, "study-case" for a study member.
1433 @param[in] study_id Owning study identity, for a study member.
1434 @param[in] case_id Member identity within its study.
1435 @return JSON-serializable run manifest.
1436 """
1437 existing = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
1438 created_at = existing.get("created_at") or datetime.now().astimezone().isoformat()
1439 # The manifest describes the run, not the latest invocation: a post-only pass over a
1440 # solved run keeps the solve it records, and each stage keeps the rank count it ran on.
1441 requested_now = dict(stages_requested or {})
1442 stages_requested = {stage: bool(requested_now.get(stage) or (existing.get("stages_requested") or {}).get(stage))
1443 for stage in {**(existing.get("stages_requested") or {}), **requested_now}}
1444 stages_completed = list(dict.fromkeys(
1445 list(existing.get("stages_completed_or_submitted") or []) + list(stages_completed or [])))
1446 if not requested_now.get("solve") and "solver_num_procs" in existing:
1447 num_procs = existing["solver_num_procs"]
1448 if not requested_now.get("post_process") and "post_num_procs" in existing:
1449 post_num_procs = existing["post_num_procs"]
1450 payload = {
1451 "schema_version": RUN_MANIFEST_SCHEMA_VERSION,
1452 "artifact_type": artifact_type,
1453 "run_id": run_id,
1454 "study_id": study_id,
1455 "case_id": case_id,
1456 "created_at": created_at,
1457 "updated_at": datetime.now().astimezone().isoformat(),
1458 "workspace": (
1459 (load_workspace_config(workspace_root).get("workspace") or {})
1460 if workspace_root else None
1461 ),
1462 "software": dict(picurv_build()),
1463 # The conductor's identity says which source staged the run; the binaries'
1464 # says which build produced its checkpoints. They can differ - an edited C
1465 # tree that was never rebuilt - so provenance records both.
1466 "binaries": runtime_build_identities(run_dir=run_dir),
1467 "launch_mode": launch_mode,
1468 "num_procs": num_procs,
1469 "solver_num_procs": num_procs,
1470 "post_num_procs": post_num_procs,
1471 "stages_requested": stages_requested or {},
1472 "stages_completed_or_submitted": stages_completed or [],
1473 "inputs": inputs or {},
1474 "paths": dict(CANONICAL_RUN_PATHS),
1475 # Where the run lived when its files were generated; adopt_relocated_artifact()
1476 # compares it with where the run is now.
1477 "root_path": os.path.abspath(run_dir),
1478 # Fixed schema, every component present whatever happened, so a reader never
1479 # has to distinguish "absent because it was not asked for" from "absent because
1480 # it failed" by looking for empty directories.
1481 "components": _run_component_states(run_dir, stages_requested or {}),
1482 "assets": (asset_lock or {}).get("assets", {}),
1483 "runtime_providers": (asset_lock or {}).get("runtime_providers", {}),
1484 # Always present, so a reader distinguishes "this run began from nothing" from
1485 # "this manifest predates lineage" by the value rather than by the key's absence.
1486 # A re-staged continuation keeps the lineage recorded when the run was created:
1487 # what a run branched from does not change because it was resumed.
1488 "lineage": lineage or existing.get("lineage") or {"relationship": "root"},
1489 "submission": submission or {},
1490 }
1491 return payload
1492
1493
1494def _recorded_run_root(run_dir: str):
1495 """!
1496 @brief Return the location a run's generated files refer to.
1497 @details Runs staged before the manifest recorded `root_path` are read from the
1498 active control file, whose generated input paths all start at the run root.
1499 @param[in] run_dir Current run directory.
1500 @return Recorded absolute root, or None when it cannot be determined.
1501 """
1502 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
1503 if manifest.get("root_path"):
1504 return manifest["root_path"]
1505 control = auto_identify_run_inputs(os.path.join(run_dir, CANONICAL_RUN_PATHS["config"]))[2]
1506 if not (control and os.path.isfile(control)):
1507 return None
1508 markers = tuple(f"/{CANONICAL_RUN_PATHS[key]}/" for key in ("config", "inputs"))
1509 with open(control, "r", encoding="utf-8", errors="replace") as stream:
1510 for line in stream:
1511 for token in line.split()[1:]:
1512 for marker in markers:
1513 if not (token.startswith("/") and marker in token):
1514 continue
1515 candidate, rest = token.split(marker, 1)
1516 # Only a path that names a file of this run identifies its old root;
1517 # an external input that merely contains the same segment does not.
1518 if os.path.exists(os.path.join(run_dir, marker.strip("/"), rest)):
1519 return candidate
1520 return None
1521
1522
1523def adopt_relocated_artifact(root: str, kind: str = "run", dry_run: bool = False) -> int:
1524 """!
1525 @brief Point a moved run's or study's generated files at where it now lives.
1526 @details Generated control files, recipes, scheduler scripts, and case indexes hold
1527 absolute paths, so a run or study moved or copied to another directory would
1528 otherwise launch against its old location. The rewrite is the one a
1529 relocated storage restore applies, after which the recorded root is updated.
1530 @param[in] root Current run or study directory.
1531 @param[in] kind "run" or "study".
1532 @param[in] dry_run Report the relocation without rewriting anything.
1533 @return Number of files rewritten (0 when the artifact has not moved).
1534 """
1535 root = os.path.abspath(root)
1536 if kind == "study":
1537 manifest_path = os.path.join(root, "study_manifest.json")
1538 recorded = ((_read_json_if_exists(manifest_path) or {}).get("paths") or {}).get("study_dir")
1539 else:
1540 manifest_path = os.path.join(root, "manifest.json")
1541 recorded = _recorded_run_root(root)
1542 if not recorded or os.path.realpath(recorded) == os.path.realpath(root) or recorded == root:
1543 return 0
1544 if dry_run:
1545 print(f"[WARNING] This {kind} was moved from {recorded}; a real invocation first updates "
1546 "its generated files to the new location.", file=sys.stderr)
1547 return 0
1548 changed = _rebase_restored_text_paths(root, [(recorded, root)])
1549 manifest = _read_json_if_exists(manifest_path)
1550 if isinstance(manifest, dict):
1551 if kind == "study":
1552 manifest.setdefault("paths", {})["study_dir"] = root
1553 else:
1554 manifest["root_path"] = root
1555 write_json_file(manifest_path, manifest)
1556 print(f"[INFO] This {kind} was moved from {recorded}; updated {len(changed)} generated "
1557 "file(s) to its new location.")
1558 return len(changed)
1559
1560
1561def _checkpoint_bundle_path(source_dir: str, step: int) -> str:
1562 """!
1563 @brief Resolve a run/output root or an exact checkpoint bundle.
1564 @param[in] source_dir Run/output root or exact bundle path.
1565 @param[in] step Checkpoint step to resolve.
1566 @return Absolute path to the checkpoint bundle.
1567 """
1568 source_dir = os.path.abspath(source_dir)
1569 if os.path.isfile(os.path.join(source_dir, "checkpoint.meta")):
1570 return source_dir
1571 return os.path.join(source_dir, "checkpoints", f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}")
1572
1573
1574def _read_checkpoint_options(metadata_path: str) -> dict:
1575 """!
1576 @brief Parse the deliberately small PETSc-options checkpoint manifest.
1577 @param[in] metadata_path Path to checkpoint.meta.
1578 @return Mapping of option names to scalar text values.
1579 """
1580 options = {}
1581 with open(metadata_path, "r", encoding="utf-8") as stream:
1582 for line_number, raw_line in enumerate(stream, start=1):
1583 tokens = shlex.split(raw_line, comments=True, posix=True)
1584 if not tokens:
1585 continue
1586 if len(tokens) != 2 or not tokens[0].startswith("-"):
1587 raise ValueError(
1588 f"Invalid checkpoint metadata line {line_number} in {metadata_path}."
1589 )
1590 key = tokens[0][1:]
1591 if key in options:
1592 raise ValueError(f"Duplicate checkpoint metadata key '-{key}' in {metadata_path}.")
1593 options[key] = tokens[1]
1594 return options
1595
1596
1598 """!
1599 @brief A committed checkpoint written by a PETSc of the other index width than the driven solver's.
1600 @details Raised instead of a plain ValueError so a scan for usable checkpoints reports it
1601 rather than skipping the checkpoint as if it were incomplete.
1602 """
1603
1604
1605def validate_committed_checkpoint(source_dir: str, step: int, require_particles: bool = False) -> dict:
1606 """!
1607 @brief Validate one committed bundle using the same manifest contract as C.
1608 @param[in] source_dir Run/output root or exact bundle path.
1609 @param[in] step Expected checkpoint step.
1610 @param[in] require_particles Whether particle restart payloads are required.
1611 @return Validated bundle path, metadata, and payload inventory.
1612 """
1613 bundle = _checkpoint_bundle_path(source_dir, step)
1614 metadata_path = os.path.join(bundle, "checkpoint.meta")
1615 commit_path = os.path.join(bundle, "COMMITTED")
1616 if not os.path.isfile(metadata_path) or not os.path.isfile(commit_path):
1617 raise ValueError(f"No committed checkpoint for step {step}: {bundle}")
1618
1619 with open(commit_path, "r", encoding="ascii") as stream:
1620 expected_digest = stream.read().strip()
1621 if not re.fullmatch(r"[0-9a-fA-F]{64}", expected_digest):
1622 raise ValueError(f"Invalid checkpoint commit marker: {commit_path}")
1623 with open(metadata_path, "rb") as stream:
1624 actual_digest = hashlib.sha256(stream.read()).hexdigest()
1625 if actual_digest.lower() != expected_digest.lower():
1626 raise ValueError(f"Checkpoint metadata hash mismatch: {bundle}")
1627
1628 options = _read_checkpoint_options(metadata_path)
1629 try:
1630 saved_version = int(options.get("checkpoint_version", "-1"))
1631 saved_step = int(options.get("checkpoint_step", "-1"))
1632 payload_count = int(options.get("checkpoint_payload_count", "-1"))
1633 particle_count = int(options.get("checkpoint_particle_count", "-1"))
1634 block_count = int(options.get("checkpoint_block_count", "-1"))
1635 float(options["checkpoint_time"])
1636 except (KeyError, TypeError, ValueError) as exc:
1637 raise ValueError(f"Checkpoint metadata is incomplete or malformed: {metadata_path}") from exc
1638 if options.get("checkpoint_format") != CHECKPOINT_FORMAT or saved_version != CHECKPOINT_VERSION:
1639 raise ValueError(f"Unsupported checkpoint format/version: {metadata_path}")
1640 if saved_step != step:
1641 raise ValueError(f"Checkpoint records step {saved_step}, expected {step}: {bundle}")
1642 # Checked once the command has selected its build; restart resolution validates before
1643 # selecting, and selection then takes this checkpoint's width, so the two agree.
1644 saved_index_bytes = int(options.get("checkpoint_petsc_index_bytes", "4"))
1645 solver_index_bytes = _SELECTED_PETSC_INDEX_BYTES or saved_index_bytes
1646 if saved_index_bytes != solver_index_bytes:
1647 raise CheckpointIndexWidthError(
1648 f"Checkpoint {bundle} was written by a PETSc with {8 * saved_index_bytes}-bit indices, but this "
1649 f"picurv drives a {8 * solver_index_bytes}-bit-index build ({resolve_runtime_executable('simulator')}). "
1650 f"Restart or post-process it with the PICurv built on a {8 * saved_index_bytes}-bit-index PETSc.")
1651 if payload_count <= 0 or particle_count < 0 or block_count <= 0:
1652 raise ValueError(f"Checkpoint inventory counts are invalid: {metadata_path}")
1653
1654 payloads = []
1655 for index in range(payload_count):
1656 prefix = f"checkpoint_payload_{index}_"
1657 relative_path = options.get(prefix + "path")
1658 try:
1659 expected_bytes = int(options[prefix + "bytes"])
1660 except (KeyError, TypeError, ValueError) as exc:
1661 raise ValueError(f"Checkpoint payload {index} has invalid metadata: {metadata_path}") from exc
1662 if not relative_path or os.path.isabs(relative_path) or ".." in relative_path.split("/"):
1663 raise ValueError(f"Checkpoint payload {index} has an unsafe path: {relative_path!r}")
1664 payload_path = os.path.join(bundle, *relative_path.split("/"))
1665 if not os.path.isfile(payload_path) or os.path.getsize(payload_path) != expected_bytes:
1666 raise ValueError(f"Checkpoint payload is missing or truncated: {payload_path}")
1667 payloads.append({
1668 "path": relative_path,
1669 "kind": options.get(prefix + "kind"),
1670 "field": options.get(prefix + "field"),
1671 "block": options.get(prefix + "block"),
1672 })
1673
1674 for block in range(block_count):
1675 fields = {
1676 item["field"] for item in payloads
1677 if item["kind"] == "eulerian" and item["block"] == str(block)
1678 }
1679 missing = CHECKPOINT_REQUIRED_EULERIAN_FIELDS - fields
1680 if missing:
1681 raise ValueError(
1682 f"Checkpoint block {block} is missing required Eulerian field(s): "
1683 f"{', '.join(sorted(missing))}."
1684 )
1685 particle_payloads = [item for item in payloads if item["kind"] == "particle"]
1686 has_particles = options.get("checkpoint_particles", "false").lower() == "true"
1687 if (not has_particles and particle_count != 0) or (has_particles and not particle_payloads):
1688 raise ValueError(f"Checkpoint particle inventory is inconsistent: {metadata_path}")
1689 if require_particles and not has_particles:
1690 raise ValueError(f"Checkpoint step {step} does not contain particle state: {bundle}")
1691
1692 return {
1693 "bundle": bundle,
1694 "metadata": options,
1695 "payloads": payloads,
1696 "has_particles": has_particles,
1697 "particle_count": particle_count,
1698 }
1699
1700
1701def _scan_committed_checkpoint_steps(source_dir: str, require_particles: bool = False) -> "set[int]":
1702 """!
1703 @brief Return only fully validated, committed checkpoint steps.
1704 @param[in] source_dir Run/output root containing checkpoints.
1705 @param[in] require_particles Whether particle restart payloads are required.
1706 @return Set of valid committed step numbers.
1707 """
1708 checkpoints_dir = os.path.join(os.path.abspath(source_dir), "checkpoints")
1709 if not os.path.isdir(checkpoints_dir):
1710 return set()
1711 pattern = re.compile(rf"^step_(\d{{{CHECKPOINT_STEP_WIDTH}}})$")
1712 steps = set()
1713 for name in os.listdir(checkpoints_dir):
1714 match = pattern.fullmatch(name)
1715 if not match:
1716 continue
1717 step = int(match.group(1))
1718 try:
1719 validate_committed_checkpoint(source_dir, step, require_particles=require_particles)
1720 except CheckpointIndexWidthError:
1721 raise
1722 except ValueError:
1723 continue
1724 steps.add(step)
1725 return steps
1726
1727
1728def parse_slurm_time_limit_to_seconds(time_text: str) -> int:
1729 """!
1730 @brief Parse a Slurm time-limit string into total seconds.
1731 @param[in] time_text Argument passed to `parse_slurm_time_limit_to_seconds()`.
1732 @return Value returned by `parse_slurm_time_limit_to_seconds()`.
1733 """
1734 text = str(time_text).strip()
1735 if not text:
1736 raise ValueError("time limit cannot be empty")
1737
1738 days = 0
1739 clock_text = text
1740 if "-" in text:
1741 day_text, clock_text = text.split("-", 1)
1742 if not day_text.isdigit():
1743 raise ValueError(f"invalid day field '{day_text}'")
1744 days = int(day_text)
1745 if not clock_text:
1746 raise ValueError("missing time portion after day field")
1747
1748 parts = clock_text.split(":")
1749 if len(parts) > 3:
1750 raise ValueError(f"unsupported time format '{time_text}'")
1751 if any(part == "" for part in parts):
1752 raise ValueError(f"malformed time field '{time_text}'")
1753 if any(not part.isdigit() for part in parts):
1754 raise ValueError(f"non-numeric time field '{time_text}'")
1755
1756 nums = [int(part) for part in parts]
1757 if days > 0:
1758 if len(nums) == 1:
1759 hours, minutes, seconds = nums[0], 0, 0
1760 elif len(nums) == 2:
1761 hours, minutes = nums
1762 seconds = 0
1763 else:
1764 hours, minutes, seconds = nums
1765 else:
1766 if len(nums) == 1:
1767 hours, minutes, seconds = 0, nums[0], 0
1768 elif len(nums) == 2:
1769 hours = 0
1770 minutes, seconds = nums
1771 else:
1772 hours, minutes, seconds = nums
1773
1774 if minutes >= 60 or seconds >= 60:
1775 raise ValueError(f"minutes and seconds must be < 60 in '{time_text}'")
1776 if days == 0 and len(nums) == 3 and hours < 0:
1777 raise ValueError(f"hours must be non-negative in '{time_text}'")
1778
1779 total_seconds = (((days * 24) + hours) * 60 + minutes) * 60 + seconds
1780 if total_seconds <= 0:
1781 raise ValueError("time limit must be positive")
1782 return total_seconds
1783
1784
1785def resolve_walltime_guard_policy(cluster_cfg: "dict | None") -> "dict | None":
1786 """!
1787 @brief Resolve the effective Slurm walltime-guard policy for generated solver jobs.
1788 @param[in] cluster_cfg Argument passed to `resolve_walltime_guard_policy()`.
1789 @return Value returned by `resolve_walltime_guard_policy()`.
1790 """
1791 if not isinstance(cluster_cfg, dict):
1792 return None
1793
1794 scheduler = cluster_cfg.get("scheduler", {}) or {}
1795 if str(scheduler.get("type", "slurm")).lower() != "slurm":
1796 return None
1797
1798 execution = cluster_cfg.get("execution", {}) or {}
1799 guard_cfg = execution.get("walltime_guard")
1800 if guard_cfg is None:
1801 guard_cfg = {}
1802 elif not isinstance(guard_cfg, dict):
1803 raise ValueError("execution.walltime_guard must be a mapping when provided")
1804
1805 policy = copy.deepcopy(DEFAULT_WALLTIME_GUARD_POLICY)
1806 policy.update(guard_cfg)
1807 policy["enabled"] = bool(policy["enabled"])
1808 policy["warmup_steps"] = int(policy["warmup_steps"])
1809 policy["multiplier"] = float(policy["multiplier"])
1810 policy["min_seconds"] = float(policy["min_seconds"])
1811 policy["estimator_alpha"] = float(policy["estimator_alpha"])
1812 return policy
1813
1814
1815def build_walltime_guard_exports(cluster_cfg: "dict | None") -> dict:
1816 """!
1817 @brief Build shell-evaluated environment exports for the runtime walltime guard.
1818 @param[in] cluster_cfg Argument passed to `build_walltime_guard_exports()`.
1819 @return Value returned by `build_walltime_guard_exports()`.
1820 """
1821 policy = resolve_walltime_guard_policy(cluster_cfg)
1822 if not policy or not policy.get("enabled", False):
1823 return {}
1824 walltime_limit_seconds = parse_slurm_time_limit_to_seconds(cluster_cfg.get("resources", {}).get("time", ""))
1825 return {
1826 WALLTIME_GUARD_ENV_JOB_START_EPOCH: "$(date +%s)",
1827 WALLTIME_GUARD_ENV_LIMIT_SECONDS: str(walltime_limit_seconds),
1828 }
1829
1830#: PetscInt width of the build the current command drives, chosen once per command by
1831#: `select_petsc_index_bytes()`; None keeps the default 32-bit `simulator`/`postprocessor`.
1832_SELECTED_PETSC_INDEX_BYTES = None
1833
1834
1835def resolve_runtime_executable(executable_name: str) -> str:
1836 """!
1837 @brief Resolve solver/post executable path, preferring local sibling binaries.
1838 @details A command that selected the 64-bit-index build resolves `simulator64` and
1839 `postprocessor64`, which `make INDEX_BITS=64` installs beside the 32-bit pair.
1840 @param[in] executable_name `simulator` or `postprocessor`.
1841 @return Absolute executable path.
1842 """
1843 if _SELECTED_PETSC_INDEX_BYTES == 8 and executable_name in RUN_EXECUTABLE_NAMES:
1844 executable_name += "64"
1845 local_candidate = os.path.join(INVOKED_SCRIPT_DIR, executable_name)
1846 if os.path.isfile(local_candidate):
1847 return os.path.abspath(local_candidate)
1848 return os.path.join(DEFAULT_BIN_DIR, executable_name)
1849
1850
1851#: Identity a native executable prints for `--version`:
1852#: `<name> <release>[.dev<N>]+g<commit>[.dirty]`; executables built before the `.dev<N>` stamp omit it.
1853_BINARY_VERSION_PATTERN = re.compile(
1854 r"^(?P<name>\S+)\s+(?P<version>(?P<release>[^+\s]+?)(?:\.dev\d+)?)\+g(?P<commit>[0-9a-f]+)(?P<dirty>\.dirty)?\s*$"
1855)
1856#: Second `--version` line: the PETSc the executable was compiled against (src/setup.c).
1857_BINARY_PETSC_PATTERN = re.compile(
1858 r"^petsc (?P<version>\S+) (?P<mode>debug|optimized)(?: (?P<indices>int32|int64))? (?P<arch>\S*) (?P<dir>.*)$"
1859)
1860#: Bytes of a PetscInt for each index width a PETSc stamp can name.
1861PETSC_INDEX_BYTES = {"int32": 4, "int64": 8}
1862#: Marker in front of the same text inside the executable, readable without running it.
1863_BINARY_PETSC_STAMP_MARKER = b"PICURV_PETSC_BUILD:"
1864#: Marker of the build-identity stamp in an executable (src/setup.c, picurv_build_stamp).
1865_BINARY_BUILD_STAMP_MARKER = b"PICURV_BUILD:"
1866
1867
1868def _parse_binary_petsc_line(line: str) -> "dict | None":
1869 """!
1870 @brief Parse the PETSc build line a native executable reports.
1871 @param[in] line `petsc <version> <debug|optimized> [int32|int64] <arch> <dir>`.
1872 @return Mapping with version, mode, indices, arch, and dir, or None when the line is not
1873 one. A binary built before the index width was stamped is 32-bit; `indices_stamped`
1874 records whether the line named it, so the line can be rebuilt exactly.
1875 """
1876 match = _BINARY_PETSC_PATTERN.match((line or "").strip())
1877 if not match:
1878 return None
1879 parsed = {key: match.group(key) for key in ("version", "mode", "arch", "dir")}
1880 parsed["indices_stamped"] = match.group("indices") is not None
1881 parsed["indices"] = match.group("indices") or "int32"
1882 return parsed
1883
1884
1885def read_binary_petsc_stamp(executable_path: str) -> "dict | None":
1886 """!
1887 @brief Read the PETSc build stamp from an executable's bytes, without running it.
1888 @details A binary built against one PETSc and started against another cannot run
1889 `--version` at all, which is exactly when knowing its PETSc matters.
1890 @param[in] executable_path Path to `simulator` or `postprocessor`.
1891 @return The parsed stamp, or None when the file carries none (built before it existed).
1892 """
1893 try:
1894 with open(executable_path, "rb") as stream:
1895 data = stream.read()
1896 except OSError:
1897 return None
1898 start = data.find(_BINARY_PETSC_STAMP_MARKER)
1899 if start < 0:
1900 return None
1901 start += len(_BINARY_PETSC_STAMP_MARKER)
1902 text = data[start:start + 4096].split(b"\0", 1)[0].split(b"\n", 1)[0]
1903 return _parse_binary_petsc_line(text.decode("utf-8", errors="replace"))
1904
1905
1906def resolved_petsc_library(executable_path: str) -> "str | None":
1907 """!
1908 @brief The PETSc shared library the dynamic loader would give this executable when picurv launches it.
1909 @details Uses `ldd` on the current shell's library path with the executable's own PETSc
1910 lib/ first, as launches and job scripts put it; returns None where `ldd` is
1911 unavailable or the executable links PETSc statically.
1912 @param[in] executable_path Path to `simulator` or `postprocessor`.
1913 @return Absolute library path, `not found`, or None.
1914 """
1915 ldd = shutil.which("ldd")
1916 if not ldd:
1917 return None
1918 env = dict(os.environ)
1919 library_dir = executable_petsc_library_dir(executable_path)
1920 if library_dir:
1921 env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, (library_dir, env.get("LD_LIBRARY_PATH"))))
1922 try:
1923 result = subprocess.run([ldd, executable_path], text=True, capture_output=True,
1924 timeout=20, check=False, env=env)
1925 except (OSError, subprocess.SubprocessError):
1926 return None
1927 for line in (result.stdout or "").splitlines():
1928 name, _, target = line.strip().partition("=>")
1929 if not name.strip().startswith("libpetsc"):
1930 continue
1931 target = target.strip()
1932 if target.startswith("not found"):
1933 return "not found"
1934 return target.split(" (")[0].strip() or None
1935 return None
1936
1937
1938def petsc_library_mismatch(petsc: "dict | None", library: "str | None") -> bool:
1939 """!
1940 @brief Whether the library the loader resolves lies outside the PETSc the binary was built against.
1941 @param[in] petsc Stamp from `read_binary_petsc_stamp()` or the `--version` line.
1942 @param[in] library Path from `resolved_petsc_library()`.
1943 @return True only when both are known and the library is not under the build's tree.
1944 """
1945 if not petsc or not petsc.get("dir") or not library:
1946 return False
1947 if library == "not found":
1948 return True
1949 build_root = os.path.realpath(os.path.join(petsc["dir"], petsc.get("arch") or ""))
1950 return not os.path.realpath(library).startswith(build_root.rstrip(os.sep) + os.sep)
1951
1952
1953def _read_binary_build_stamp(executable_path: str) -> "dict | None":
1954 """!
1955 @brief Read the build identity stamped into an executable's bytes, without running it.
1956 @details src/setup.c stores `PICURV_BUILD:<version> <commit> <dirty>` beside the PETSc
1957 stamp. Running `--version` on a login node starts the dynamic loader against a
1958 cold filesystem, tens of seconds; reading the file costs one read.
1959 @param[in] executable_path Path to `simulator` or `postprocessor`.
1960 @return The identity mapping `read_binary_build_identity()` returns, or None for a binary
1961 built before the stamp existed.
1962 """
1963 try:
1964 with open(executable_path, "rb") as stream:
1965 data = stream.read()
1966 except OSError:
1967 return None
1968 start = data.find(_BINARY_BUILD_STAMP_MARKER)
1969 if start < 0:
1970 return None
1971 fields = data[start + len(_BINARY_BUILD_STAMP_MARKER):start + 512].split(b"\0", 1)[0].decode(
1972 "utf-8", errors="replace").split()
1973 if len(fields) != 3 or not re.fullmatch(r"[0-9a-f]+", fields[1]) or fields[2] not in ("true", "false"):
1974 return None
1975 version, commit, dirty = fields[0], fields[1][:12], fields[2] == "true"
1976 release = re.sub(r"\.dev\d+$", "", version)
1977 petsc = read_binary_petsc_stamp(executable_path)
1978 if petsc is None:
1979 return None
1980 # Exactly what `--version` prints (src/setup.c), which the job-start check compares with.
1981 name = os.path.basename(executable_path)
1982 version_line = (f"{name} {version}+g{commit}{'.dirty' if dirty else ''}\n"
1983 f"petsc {petsc['version']} {petsc['mode']}"
1984 f"{' ' + petsc['indices'] if petsc.get('indices_stamped') else ''} {petsc['arch']} {petsc['dir']}")
1985 return {
1986 "available": True,
1987 "path": executable_path,
1988 "version_line": version_line,
1989 "release_version": release,
1990 "git_commit": commit,
1991 "dirty": dirty,
1992 "build_id": f"{version}+g{commit}{'.dirty' if dirty else ''}",
1993 "petsc": petsc,
1994 "identity_source": "stamp",
1995 }
1996
1997
1998def read_binary_build_identity(executable_path: str) -> dict:
1999 """!
2000 @brief Read the build identity a native executable was compiled with.
2001
2002 @details The Makefile stamps the release, commit, and dirty state into the
2003 binaries, and they are written into every checkpoint manifest. Reading it
2004 back is what makes the run manifest's provenance a statement about the
2005 binary that ran rather than about whatever source happens to be checked
2006 out when the conductor is invoked.
2007 @param[in] executable_path Path to `simulator` or `postprocessor`.
2008 @return Identity mapping, or an `available: False` mapping when it cannot be read.
2009 """
2010 if not os.path.isfile(executable_path) or not os.access(executable_path, os.X_OK):
2011 return {"available": False, "reason": "not built", "path": executable_path}
2012 stamped = _read_binary_build_stamp(executable_path)
2013 if stamped is not None:
2014 return stamped
2015 try:
2016 result = subprocess.run(
2017 [executable_path, "--version"], text=True, capture_output=True, timeout=30,
2018 )
2019 except (OSError, subprocess.SubprocessError) as exc:
2020 return {"available": False, "reason": str(exc), "path": executable_path}
2021 if result.returncode != 0:
2022 # A binary that cannot start - typically its shared libraries are not loadable
2023 # in this shell - is not the same failure as one that predates the identity
2024 # flag, and recording it as such hides a broken staging environment.
2025 detail = (result.stderr or result.stdout or "").strip().splitlines()
2026 return {"available": False,
2027 "reason": f"--version exited {result.returncode}"
2028 + (f": {detail[0]}" if detail else ""),
2029 "path": executable_path,
2030 "petsc": read_binary_petsc_stamp(executable_path)}
2031 lines = (result.stdout or "").strip().splitlines()
2032 match = _BINARY_VERSION_PATTERN.match(lines[0] if lines else "")
2033 if not match:
2034 # A binary from before the identity flag existed, or a wrapper that prints
2035 # something else. Recorded as unavailable rather than guessed at.
2036 return {"available": False, "reason": "no build identity reported",
2037 "path": executable_path}
2038 return {
2039 "available": True,
2040 "path": executable_path,
2041 "version_line": (result.stdout or "").strip(),
2042 "release_version": match.group("release"),
2043 "git_commit": match.group("commit"),
2044 "dirty": bool(match.group("dirty")),
2045 "build_id": (
2046 f"{match.group('version')}+g{match.group('commit')}"
2047 f"{'.dirty' if match.group('dirty') else ''}"
2048 ),
2049 "petsc": _parse_binary_petsc_line(lines[1]) if len(lines) > 1 else None,
2050 }
2051
2052
2053def runtime_build_identities(source_identity: "dict | None" = None,
2054 run_dir: "str | None" = None) -> dict:
2055 """!
2056 @brief Read the build identity of every native executable a run would launch.
2057 @param[in] source_identity Source identity to compare against; defaults to the
2058 identity this process computed at startup.
2059 @param[in] run_dir Run whose pinned executables are read; the installation's when None,
2060 which also lists the 64-bit-index pair (`simulator64`,
2061 `postprocessor64`) when it is built.
2062 @return Mapping of executable name to its identity, each carrying `matches_source`.
2063 """
2064 source_identity = source_identity or picurv_build()
2065 identities = {}
2066 source_commit = str(source_identity.get("git_commit") or "")
2067 paths = {name: run_executable_path(run_dir, name) for name in RUN_EXECUTABLE_NAMES}
2068 if run_dir is None and _SELECTED_PETSC_INDEX_BYTES is None:
2069 for name in RUN_EXECUTABLE_NAMES:
2070 wide = os.path.join(os.path.dirname(paths[name]), name + "64")
2071 if os.path.isfile(wide):
2072 paths[name + "64"] = wide
2073 for name, path in paths.items():
2074 identity = read_binary_build_identity(path)
2075 if identity.get("available"):
2076 # The stamped commit is abbreviated, so compare on the prefix it carries.
2077 identity["matches_source"] = bool(
2078 source_commit
2079 and source_commit.startswith(identity["git_commit"])
2080 and identity["dirty"] == bool(source_identity.get("dirty"))
2081 )
2082 identities[name] = identity
2083 return identities
2084
2085
2086def rebuild_command(name: str, identity: dict) -> str:
2087 """!
2088 @brief The command that rebuilds one native executable.
2089 @details The 64-bit-index pair needs `INDEX_BITS=64` and its own PETSc, named from the
2090 executable's PETSc stamp when it has one; a plain build makes the 32-bit pair.
2091 @param[in] name Executable name, such as `simulator64`.
2092 @param[in] identity Identity mapping from `read_binary_build_identity()`.
2093 @return Shell command text.
2094 """
2095 if not name.endswith("64"):
2096 return "picurv build"
2097 petsc = identity.get("petsc") or {}
2098 command = f"picurv build INDEX_BITS=64 PETSC_DIR={petsc.get('dir') or '<64-bit PETSc>'}"
2099 return command + (f" PETSC_ARCH={petsc['arch']}" if petsc.get("arch") else "")
2100
2101
2102def build_identity_problems(identities: dict, workspace_requirement=None,
2103 source_identity: "dict | None" = None) -> list:
2104 """!
2105 @brief Report every reason the active build identity is not internally coherent.
2106
2107 @details The conductor, the simulator, and the postprocessor are three artifacts
2108 that must agree before a run's provenance means anything. This states the
2109 disagreements rather than printing them, so `version status` can exit on
2110 them while ordinary staging only warns.
2111 @param[in] identities Mapping returned by `runtime_build_identities()`.
2112 @param[in] workspace_requirement Optional `software.picurv` constraint to check.
2113 @param[in] source_identity Source identity the executables must match; defaults to
2114 the identity this process computed at startup.
2115 @return Human-readable problem descriptions; empty when the build is coherent.
2116 """
2117 source_identity = source_identity or picurv_build()
2118 problems = []
2119 for name, identity in sorted(identities.items()):
2120 if not identity.get("available"):
2121 problems.append(
2122 f"{name}: no build identity available ({identity.get('reason', 'unknown')}); "
2123 f"rebuild with '{rebuild_command(name, identity)}'."
2124 )
2125 elif not identity.get("matches_source"):
2126 problems.append(
2127 f"{name}: built from {identity['build_id']}, but the active source is "
2128 f"{source_identity['build_id']}; rebuild with '{rebuild_command(name, identity)}'."
2129 )
2130 if workspace_requirement not in (None, ""):
2131 try:
2132 from packaging.specifiers import SpecifierSet
2133 from packaging.version import Version
2134 requirement_text = str(workspace_requirement).strip()
2135 if not any(token in requirement_text for token in "<>=!~"):
2136 requirement_text = "==" + requirement_text
2137 satisfied = Version(PICURV_RELEASE_VERSION) in SpecifierSet(requirement_text)
2138 except Exception:
2139 problems.append(
2140 f"workspace: software.picurv={workspace_requirement!r} is not a valid "
2141 "version or version range."
2142 )
2143 else:
2144 if not satisfied:
2145 problems.append(
2146 f"workspace: requires PICurv {workspace_requirement!r}, but the active "
2147 f"release is {PICURV_RELEASE_VERSION}."
2148 )
2149 return problems
2150
2151
2152def warn_on_stale_runtime_binaries(identities: dict) -> list:
2153 """!
2154 @brief Report native executables whose build identity is not the active source.
2155 @details An executable whose identity cannot be read is reported too: the manifest
2156 would otherwise record "unavailable" for a run with no word at staging.
2157 @param[in] identities Mapping returned by `runtime_build_identities()`.
2158 @return Names of executables that disagree with the active source identity.
2159 """
2160 stale = [name for name, identity in sorted(identities.items())
2161 if identity.get("available") and not identity.get("matches_source")]
2162 for name in stale:
2163 print(
2164 f"[WARNING] {name} was built from {identities[name]['build_id']}, but the active "
2165 f"source is {picurv_build()['build_id']}. Checkpoints will record the binary's "
2166 f"identity, not the source's. Rebuild with '{rebuild_command(name, identities[name])}'.",
2167 file=sys.stderr,
2168 )
2169 for name, identity in sorted(identities.items()):
2170 if identity.get("available"):
2171 continue
2172 print(
2173 f"[WARNING] The build identity of {name} could not be read "
2174 f"({identity.get('reason', 'unknown')}); this run's manifest will not record "
2175 f"which build it used. Path: {identity.get('path')}",
2176 file=sys.stderr,
2177 )
2178 return stale
2179
2180# Standardized error codes used for CLI/validation reporting.
2181ERROR_CODE_CLI_USAGE_INVALID = "CLI_USAGE_INVALID"
2182ERROR_CODE_CFG_MISSING_SECTION = "CFG_MISSING_SECTION"
2183ERROR_CODE_CFG_MISSING_KEY = "CFG_MISSING_KEY"
2184ERROR_CODE_CFG_INVALID_TYPE = "CFG_INVALID_TYPE"
2185ERROR_CODE_CFG_INVALID_VALUE = "CFG_INVALID_VALUE"
2186ERROR_CODE_CFG_FILE_NOT_FOUND = "CFG_FILE_NOT_FOUND"
2187ERROR_CODE_CFG_GRID_PARSE = "CFG_GRID_PARSE"
2188ERROR_CODE_CFG_INCONSISTENT_COMBO = "CFG_INCONSISTENT_COMBO"
2189ERROR_CODE_DEPENDENCY_MISSING = "DEPENDENCY_MISSING"
2190
2191RESTART_RUN_DIR_REQUIRED_MESSAGE = (
2192 "--continue and --restart-from both require an existing run directory. "
2193 "Use --continue --run-dir <run_dir> to resume in place, or "
2194 "--restart-from <run_dir> to create a new run."
2195)
2196
2197_ERROR_HINTS = {
2198 ERROR_CODE_CLI_USAGE_INVALID: "Run 'picurv <command> --help' to see valid argument combinations.",
2199 ERROR_CODE_CFG_MISSING_SECTION: "Add the missing section using examples/master_template/*.yml as reference.",
2200 ERROR_CODE_CFG_MISSING_KEY: "Add the missing key in the referenced YAML file.",
2201 ERROR_CODE_CFG_INVALID_TYPE: "Fix the value type to match the documented schema in docs/pages/14_Config_Contract.md.",
2202 ERROR_CODE_CFG_INVALID_VALUE: "Adjust the value to a supported range/enum from the config reference pages.",
2203 ERROR_CODE_CFG_FILE_NOT_FOUND: "Fix the path or create the missing file before running again.",
2204 ERROR_CODE_CFG_GRID_PARSE: "Validate grid file format and numeric payload (block count, dims, coordinates).",
2205 ERROR_CODE_CFG_INCONSISTENT_COMBO: "Fix conflicting options/keys so the configuration is internally consistent.",
2206 ERROR_CODE_DEPENDENCY_MISSING: "Install the named optional dependency for the Python interpreter used by picurv.",
2207}
2208#: Hint for a refused restart: the flags and the case key that choose its source and step.
2209_RESTART_HINT = ("Check --restart-from, --continue and --run-dir against the source run's committed "
2210 "checkpoints and run_control.start_step in case.yml.")
2211#: Hint for a workflow refused because the payload it reads is in cold storage.
2212_STORAGE_RESTORE_HINT = ("Run the restore command in the message; 'picurv storage status --run-dir "
2213 "<run>' shows what is local.")
2214#: Hint for a --study-dir that is not a study `picurv sweep` created.
2215_STUDY_DIR_HINT = "Pass a study directory that 'picurv sweep' created (studies/<study_id>)."
2216
2217
2218def _sanitize_error_field(value) -> str:
2219 """!
2220 @brief Normalize error fields into a single-line string.
2221 @param[in] value Argument passed to `_sanitize_error_field()`.
2222 @return Value returned by `_sanitize_error_field()`.
2223 """
2224 if value is None:
2225 return "-"
2226 text = str(value).strip()
2227 if not text:
2228 return "-"
2229 return " ".join(text.splitlines())
2230
2231
2232def emit_structured_error(code: str, key: str = "-", file_path: str = "-",
2233 message: str = "", hint: str = None, stream=None):
2234 """!
2235 @brief Emit one standardized error line for tooling and users.
2236 @param[in] code Argument passed to `emit_structured_error()`.
2237 @param[in] key Argument passed to `emit_structured_error()`.
2238 @param[in] file_path Argument passed to `emit_structured_error()`.
2239 @param[in] message Argument passed to `emit_structured_error()`.
2240 @param[in] hint Argument passed to `emit_structured_error()`.
2241 @param[in] stream Argument passed to `emit_structured_error()`.
2242 """
2243 if stream is None:
2244 stream = sys.stderr
2245 resolved_hint = hint if hint is not None else _ERROR_HINTS.get(code, "-")
2246 print(
2247 f"ERROR {_sanitize_error_field(code)} | "
2248 f"key={_sanitize_error_field(key)} | "
2249 f"file={_sanitize_error_field(file_path)} | "
2250 f"message={_sanitize_error_field(message)} | "
2251 f"hint={_sanitize_error_field(resolved_hint)}",
2252 file=stream,
2253 )
2254
2255
2256def fail_cli_usage(message: str, hint: str = None):
2257 """!
2258 @brief Emit a structured CLI usage error and exit with code 2.
2259 @details Without a @p hint, the default names the command the user ran.
2260 @param[in] message Argument passed to `fail_cli_usage()`.
2261 @param[in] hint Argument passed to `fail_cli_usage()`.
2262 """
2263 command = next((token for token in sys.argv[1:] if not token.startswith("-")), "")
2265 ERROR_CODE_CLI_USAGE_INVALID,
2266 key="-",
2267 file_path="-",
2268 message=message,
2269 hint=hint or _ERROR_HINTS[ERROR_CODE_CLI_USAGE_INVALID].replace(
2270 "<command>", command if re.fullmatch(r"[a-z][a-z-]*", command) else "<command>"),
2271 )
2272 sys.exit(2)
2273
2274
2276 """!
2277 @brief Separate a validation error into its source-file and message fields when possible.
2278 @param[in] raw_error Validation error text, optionally beginning with a file path and colon.
2279 @return A pair containing the detected file path (or ``-``) and the message text.
2280 """
2281 text = str(raw_error).strip()
2282 match = re.match(r"^(?P<file>[^:]+):\s*(?P<msg>.+)$", text)
2283 if not match:
2284 return "-", text
2285 file_candidate = match.group("file").strip()
2286 msg = match.group("msg").strip()
2287 known_suffixes = (".yml", ".yaml", ".cfg", ".picgrid", ".control", ".run", ".txt")
2288 if "/" in file_candidate or file_candidate.endswith(known_suffixes):
2289 return file_candidate, msg
2290 return "-", text
2291
2292
2293def _extract_key_path(message: str) -> str:
2294 """!
2295 @brief Best-effort key-path extraction from free-form validation messages.
2296 @param[in] message Argument passed to `_extract_key_path()`.
2297 @return Value returned by `_extract_key_path()`.
2298 """
2299 dotted = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_\[\]-]+)+)\b", message)
2300 if dotted:
2301 return dotted.group(1)
2302
2303 bracketed = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*\[[^\]]+\](?:\[[^\]]+\])*)\b", message)
2304 if bracketed:
2305 return bracketed.group(1)
2306
2307 quoted = re.findall(r"'([A-Za-z0-9_.\[\]-]+)'", message)
2308 for token in quoted:
2309 if "." in token or "[" in token or token.isidentifier():
2310 return token
2311 return "-"
2312
2313
2314def _classify_error_code(message: str) -> str:
2315 """!
2316 @brief Map existing validation/error messages to the standardized code set.
2317 @param[in] message Argument passed to `_classify_error_code()`.
2318 @return Value returned by `_classify_error_code()`.
2319 """
2320 msg = message.lower()
2321 if "missing required section" in msg:
2322 return ERROR_CODE_CFG_MISSING_SECTION
2323 if "missing required key" in msg or "missing key" in msg:
2324 return ERROR_CODE_CFG_MISSING_KEY
2325 if "not found" in msg or "does not exist" in msg:
2326 return ERROR_CODE_CFG_FILE_NOT_FOUND
2327 if "invalid dimensions line" in msg or "invalid coordinate row" in msg or "grid file" in msg:
2328 return ERROR_CODE_CFG_GRID_PARSE
2329 if (
2330 "must both be periodic" in msg
2331 or "inconsistent periodicity" in msg
2332 or "mismatch" in msg
2333 or "requires --" in msg
2334 or "must be 1 (auto) or exactly" in msg
2335 ):
2336 return ERROR_CODE_CFG_INCONSISTENT_COMBO
2337 if (
2338 "must be a mapping" in msg
2339 or "must be a list" in msg
2340 or "must be a string" in msg
2341 or "must be a boolean" in msg
2342 or "must be either" in msg
2343 ):
2344 return ERROR_CODE_CFG_INVALID_TYPE
2345 if "unsupported key" in msg or "unsupported top-level section" in msg:
2346 return ERROR_CODE_CFG_INVALID_VALUE
2347 return ERROR_CODE_CFG_INVALID_VALUE
2348
2349# ==============================================================================
2350# HELPER FUNCTIONS
2351# ==============================================================================
2352
2353def read_yaml_file(filepath: str) -> dict:
2354 """!
2355 @brief Safely reads a YAML file and returns its content.
2356 @param[in] filepath Path to the YAML file.
2357 @return A dictionary containing the parsed YAML content.
2358 @throws SystemExit if the file is not found or cannot be parsed.
2359 """
2360 if not os.path.exists(filepath):
2362 ERROR_CODE_CFG_FILE_NOT_FOUND,
2363 key="-",
2364 file_path=filepath,
2365 message="Configuration file not found.",
2366 )
2367 sys.exit(1)
2368 try:
2369 with open(filepath, 'r') as f:
2370 return yaml.safe_load(f)
2371 except yaml.YAMLError as e:
2373 ERROR_CODE_CFG_INVALID_VALUE,
2374 key="-",
2375 file_path=filepath,
2376 message=f"YAML parse error: {e}",
2377 hint="Fix YAML syntax/indentation and retry validation.",
2378 )
2379 sys.exit(1)
2380
2381def write_yaml_file(filepath: str, data: dict):
2382 """!
2383 @brief Write YAML with stable ordering for generated study artifacts.
2384 @param[in] filepath Argument passed to `write_yaml_file()`.
2385 @param[in] data Argument passed to `write_yaml_file()`.
2386 """
2387 path = os.path.abspath(filepath)
2388 os.makedirs(os.path.dirname(path), exist_ok=True)
2389 temporary = f"{path}.tmp.{os.getpid()}"
2390 with open(temporary, "w") as f:
2391 yaml.safe_dump(data, f, sort_keys=False)
2392 f.flush()
2393 os.fsync(f.fileno())
2394 os.replace(temporary, path)
2395
2396def write_json_file(filepath: str, payload: dict):
2397 """!
2398 @brief Write JSON metadata/manifests with a stable, readable format.
2399 @param[in] filepath Argument passed to `write_json_file()`.
2400 @param[in] payload Argument passed to `write_json_file()`.
2401 """
2402 path = os.path.abspath(filepath)
2403 os.makedirs(os.path.dirname(path), exist_ok=True)
2404 temporary = f"{path}.tmp.{os.getpid()}"
2405 with open(temporary, "w") as f:
2406 json.dump(payload, f, indent=2, sort_keys=True)
2407 f.write("\n")
2408 f.flush()
2409 os.fsync(f.fileno())
2410 os.replace(temporary, path)
2411
2412
2413def write_runtime_execution_file(filepath: str, template_source_path: str = None) -> str:
2414 """!
2415 @brief Write a default runtime execution config, copying a source template when available.
2416 @param[in] filepath Argument passed to `write_runtime_execution_file()`.
2417 @param[in] template_source_path Argument passed to `write_runtime_execution_file()`.
2418 @return Value returned by `write_runtime_execution_file()`.
2419 """
2420 os.makedirs(os.path.dirname(filepath), exist_ok=True)
2421
2422 if template_source_path and os.path.isfile(template_source_path):
2423 shutil.copy2(template_source_path, filepath)
2424 return filepath
2425
2426 with open(filepath, "w", encoding="utf-8") as f:
2427 f.write(DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE)
2428 return filepath
2429
2430
2432 """!
2433 @brief Return True when a launcher arg token contains embedded whitespace and should be split.
2434 @param[in] token Argument passed to `_launcher_arg_contains_whitespace()`.
2435 @return Value returned by `_launcher_arg_contains_whitespace()`.
2436 """
2437 return isinstance(token, str) and any(ch.isspace() for ch in token.strip())
2438
2439
2440def resolve_runtime_execution_seed_source(source_project_root: str) -> "str | None":
2441 """!
2442 @brief Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
2443 @param[in] source_project_root Argument passed to `resolve_runtime_execution_seed_source()`.
2444 @return Value returned by `resolve_runtime_execution_seed_source()`.
2445 """
2446 source_root_abs = os.path.abspath(source_project_root)
2447 repo_local_runtime = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2448 if os.path.isfile(repo_local_runtime):
2449 return repo_local_runtime
2450
2451 tracked_example = os.path.join(
2452 source_root_abs,
2453 "config",
2454 "runtime",
2455 RUNTIME_EXECUTION_EXAMPLE_FILENAME,
2456 )
2457 if os.path.isfile(tracked_example):
2458 return tracked_example
2459 return None
2460
2461
2462def ensure_case_runtime_execution_config(case_dir: str, source_project_root: str, overwrite: bool = False) -> dict:
2463 """!
2464 @brief Create case-local runtime execution config if missing, seeded from repo-local config when available.
2465 @param[in] case_dir Argument passed to `ensure_case_runtime_execution_config()`.
2466 @param[in] source_project_root Argument passed to `ensure_case_runtime_execution_config()`.
2467 @param[in] overwrite Argument passed to `ensure_case_runtime_execution_config()`.
2468 @return Value returned by `ensure_case_runtime_execution_config()`.
2469 """
2470 case_dir_abs = os.path.abspath(case_dir)
2471 dest_path = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2472 if os.path.exists(dest_path) and not overwrite:
2473 return {
2474 "path": dest_path,
2475 "created": False,
2476 "seed_source": None,
2477 }
2478
2479 seed_source = resolve_runtime_execution_seed_source(source_project_root)
2480 write_runtime_execution_file(dest_path, seed_source)
2481 return {
2482 "path": dest_path,
2483 "created": True,
2484 "seed_source": seed_source,
2485 }
2486
2487
2488def is_project_root(candidate: str) -> bool:
2489 """!
2490 @brief Return True when a directory looks like the PICurv source repository root.
2491 @param[in] candidate Argument passed to `is_project_root()`.
2492 @return Value returned by `is_project_root()`.
2493 """
2494 if not candidate:
2495 return False
2496 candidate_abs = os.path.abspath(candidate)
2497 return (
2498 os.path.isfile(os.path.join(candidate_abs, "Makefile"))
2499 and os.path.isdir(os.path.join(candidate_abs, "src"))
2500 and os.path.isdir(os.path.join(candidate_abs, "include"))
2501 and os.path.isdir(os.path.join(candidate_abs, "picurv_cli"))
2502 )
2503
2504
2505def _iter_parent_dirs(start_path: str):
2506 """!
2507 @brief Yield a path and all of its parents up to filesystem root.
2508 @param[in] start_path Argument passed to `_iter_parent_dirs()`.
2509 """
2510 current = os.path.abspath(start_path)
2511 if os.path.isfile(current):
2512 current = os.path.dirname(current)
2513 while True:
2514 yield current
2515 parent = os.path.dirname(current)
2516 if parent == current:
2517 break
2518 current = parent
2519
2520
2521def find_project_root_upwards(start_path: str):
2522 """!
2523 @brief Search upward from an anchor and return the first matching project root.
2524 @param[in] start_path Argument passed to `find_project_root_upwards()`.
2525 @return Value returned by `find_project_root_upwards()`.
2526 """
2527 if not start_path:
2528 return None
2529 for directory in _iter_parent_dirs(start_path):
2530 if is_project_root(directory):
2531 return directory
2532 return None
2533
2534
2536 """!
2537 @brief Best-effort source repo discovery from runtime anchors.
2538 @param[in] extra_anchors Argument passed to `discover_local_project_root()`.
2539 @return Value returned by `discover_local_project_root()`.
2540 """
2541 anchors = list(extra_anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR, SCRIPT_PATH, PROJECT_ROOT]
2542 seen = set()
2543 for anchor in anchors:
2544 if not anchor:
2545 continue
2546 anchor_abs = os.path.abspath(anchor)
2547 if anchor_abs in seen:
2548 continue
2549 seen.add(anchor_abs)
2550 project_root = find_project_root_upwards(anchor_abs)
2551 if project_root:
2552 return project_root
2553 return None
2554
2555
2556def find_case_origin_metadata_file(case_dir_hint: str = None):
2557 """!
2558 @brief Find the nearest case-origin metadata file from known runtime anchors.
2559 @details An explicit case directory is the only anchor searched, so a wrong one cannot
2560 fall back to the case in the working directory.
2561 @param[in] case_dir_hint Argument passed to `find_case_origin_metadata_file()`.
2562 @return Value returned by `find_case_origin_metadata_file()`.
2563 @throws ValueError when @p case_dir_hint is given but is not a directory.
2564 """
2565 if case_dir_hint and not os.path.isdir(case_dir_hint):
2566 raise ValueError(f"Case directory does not exist: {os.path.abspath(case_dir_hint)}")
2567 search_roots = []
2568 for candidate in (case_dir_hint,) if case_dir_hint else (os.getcwd(), INVOKED_SCRIPT_DIR):
2569 if not candidate:
2570 continue
2571 abs_candidate = os.path.abspath(candidate)
2572 if abs_candidate not in search_roots:
2573 search_roots.append(abs_candidate)
2574
2575 for root in search_roots:
2576 for directory in _iter_parent_dirs(root):
2577 metadata_path = os.path.join(directory, CASE_ORIGIN_METADATA_FILENAME)
2578 if os.path.isfile(metadata_path):
2579 return metadata_path
2580 return None
2581
2582
2583def load_case_origin_metadata(case_dir_hint: str = None):
2584 """!
2585 @brief Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
2586 @param[in] case_dir_hint Argument passed to `load_case_origin_metadata()`.
2587 @return Value returned by `load_case_origin_metadata()`.
2588 """
2589 metadata_path = find_case_origin_metadata_file(case_dir_hint)
2590 if not metadata_path:
2591 return None, None, None
2592 try:
2593 with open(metadata_path, "r", encoding="utf-8") as f:
2594 payload = json.load(f)
2595 if not isinstance(payload, dict):
2596 raise ValueError("Case origin metadata must be a JSON object.")
2597 except Exception as exc:
2598 raise ValueError(f"Failed to read case origin metadata at {metadata_path}: {exc}") from exc
2599 return os.path.dirname(metadata_path), metadata_path, payload
2600
2601
2603 """!
2604 @brief Find the nearest optional execution config from runtime/case anchors.
2605 @param[in] anchors Argument passed to `find_runtime_execution_config_file()`.
2606 @return Value returned by `find_runtime_execution_config_file()`.
2607 """
2608 seen = set()
2609 search_roots = []
2610 for candidate in list(anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR]:
2611 if not candidate:
2612 continue
2613 current = os.path.abspath(candidate)
2614 if os.path.isfile(current):
2615 current = os.path.dirname(current)
2616 if current in seen:
2617 continue
2618 seen.add(current)
2619 search_roots.append(current)
2620
2621 seen_dirs = set()
2622 for root in search_roots:
2623 for directory in _iter_parent_dirs(root):
2624 if directory in seen_dirs:
2625 continue
2626 seen_dirs.add(directory)
2627 for filename in RUNTIME_EXECUTION_CONFIG_FILENAMES:
2628 config_path = os.path.join(directory, filename)
2629 if os.path.isfile(config_path):
2630 return config_path
2631 return None
2632
2633
2634def _normalize_execution_override_section(payload: dict, section_name: str, config_path: str, config_label: str) -> dict:
2635 """!
2636 @brief Validate one execution override section while preserving missing-vs-empty semantics.
2637 @param[in] payload Argument passed to `_normalize_execution_override_section()`.
2638 @param[in] section_name Argument passed to `_normalize_execution_override_section()`.
2639 @param[in] config_path Argument passed to `_normalize_execution_override_section()`.
2640 @param[in] config_label Argument passed to `_normalize_execution_override_section()`.
2641 @return Value returned by `_normalize_execution_override_section()`.
2642 """
2643 section = payload.get(section_name)
2644 if section is None:
2645 return {"launcher": None, "launcher_args": None}
2646 if not isinstance(section, dict):
2647 raise ValueError(f"{config_label} at {config_path}: {section_name} must be a mapping.")
2648
2649 launcher = section.get("launcher")
2650 if launcher is not None and not isinstance(launcher, str):
2651 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher must be a string.")
2652
2653 launcher_args = None
2654 if "launcher_args" in section:
2655 launcher_args = section.get("launcher_args", [])
2656 if launcher_args is None:
2657 launcher_args = []
2658 if not isinstance(launcher_args, list):
2659 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher_args must be a list.")
2660 for i, token in enumerate(launcher_args):
2661 if not isinstance(token, (str, int, float)):
2662 raise ValueError(
2663 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2664 "must be a scalar CLI token."
2665 )
2667 raise ValueError(
2668 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2669 "must be a single CLI token; split whitespace-separated arguments into separate list items."
2670 )
2671 launcher_args = [str(x) for x in launcher_args]
2672
2673 return {
2674 "launcher": launcher,
2675 "launcher_args": launcher_args,
2676 }
2677
2678
2679def load_runtime_execution_config(config_search_anchor: str = None, extra_search_anchors=None):
2680 """!
2681 @brief Load optional shared execution launcher config from the nearest runtime config file.
2682 @param[in] config_search_anchor Argument passed to `load_runtime_execution_config()`.
2683 @param[in] extra_search_anchors Argument passed to `load_runtime_execution_config()`.
2684 @return Value returned by `load_runtime_execution_config()`.
2685 """
2686 anchors = []
2687 if config_search_anchor is not None:
2688 anchors.append(config_search_anchor)
2689 if extra_search_anchors:
2690 anchors.extend(extra_search_anchors)
2691
2692 config_path = find_runtime_execution_config_file(*anchors)
2693 if not config_path:
2694 return None, {}
2695
2696 try:
2697 with open(config_path, "r", encoding="utf-8") as f:
2698 payload = yaml.safe_load(f) or {}
2699 except yaml.YAMLError as exc:
2700 raise ValueError(f"{os.path.basename(config_path)} YAML parse error at {config_path}: {exc}") from exc
2701
2702 if not isinstance(payload, dict):
2703 raise ValueError(f"{os.path.basename(config_path)} at {config_path} must be a YAML mapping.")
2704
2705 return config_path, {
2706 "default_execution": _normalize_execution_override_section(
2707 payload,
2708 "default_execution",
2709 config_path,
2710 os.path.basename(config_path),
2711 ),
2712 "local_execution": _normalize_execution_override_section(
2713 payload,
2714 "local_execution",
2715 config_path,
2716 os.path.basename(config_path),
2717 ),
2718 "cluster_execution": _normalize_execution_override_section(
2719 payload,
2720 "cluster_execution",
2721 config_path,
2722 os.path.basename(config_path),
2723 ),
2724 }
2725
2726
2727def merge_execution_overrides(base: "dict | None", override: "dict | None") -> dict:
2728 """!
2729 @brief Merge execution overrides, letting explicit override values win key-by-key.
2730 @param[in] base Argument passed to `merge_execution_overrides()`.
2731 @param[in] override Argument passed to `merge_execution_overrides()`.
2732 @return Value returned by `merge_execution_overrides()`.
2733 """
2734 base = base or {}
2735 override = override or {}
2736
2737 launcher = override.get("launcher")
2738 if launcher is None:
2739 launcher = base.get("launcher")
2740
2741 launcher_args = override.get("launcher_args")
2742 if launcher_args is None:
2743 launcher_args = base.get("launcher_args")
2744
2745 return {
2746 "launcher": launcher,
2747 "launcher_args": None if launcher_args is None else [str(x) for x in launcher_args],
2748 }
2749
2750
2751def resolve_runtime_execution_context(runtime_execution_cfg: dict, context: str) -> dict:
2752 """!
2753 @brief Resolve default plus context-specific execution overrides.
2754 @param[in] runtime_execution_cfg Argument passed to `resolve_runtime_execution_context()`.
2755 @param[in] context Argument passed to `resolve_runtime_execution_context()`.
2756 @return Value returned by `resolve_runtime_execution_context()`.
2757 """
2758 if context not in LAUNCH_MODES:
2759 raise ValueError(f"Unsupported execution context '{context}'.")
2761 runtime_execution_cfg.get("default_execution"),
2762 runtime_execution_cfg.get(f"{context}_execution"),
2763 )
2764
2765
2766def get_git_commit(repo_root: str = None) -> str:
2767 """!
2768 @brief Best-effort git commit lookup for run/study manifests and case metadata.
2769 @param[in] repo_root Argument passed to `get_git_commit()`.
2770 @return Value returned by `get_git_commit()`.
2771 """
2772 cwd = repo_root or PROJECT_ROOT
2773 try:
2774 result = subprocess.run(
2775 ["git", "rev-parse", "HEAD"],
2776 cwd=cwd,
2777 text=True,
2778 capture_output=True,
2779 check=False
2780 )
2781 if result.returncode == 0:
2782 return result.stdout.strip()
2783 except Exception:
2784 pass
2785 return None
2786
2787
2788def write_case_origin_metadata(case_dir: str, source_project_root: str, template_name: str = None,
2789 existing: dict = None, template_managed_files=None):
2790 """!
2791 @brief Create or refresh case-origin metadata for repo-aware case maintenance commands.
2792 @param[in] case_dir Argument passed to `write_case_origin_metadata()`.
2793 @param[in] source_project_root Argument passed to `write_case_origin_metadata()`.
2794 @param[in] template_name Argument passed to `write_case_origin_metadata()`.
2795 @param[in] existing Argument passed to `write_case_origin_metadata()`.
2796 @param[in] template_managed_files Argument passed to `write_case_origin_metadata()`.
2797 @return Value returned by `write_case_origin_metadata()`.
2798 """
2799 payload = dict(existing or {})
2800 if "initialized_at" not in payload:
2801 payload["initialized_at"] = datetime.now().isoformat()
2802 payload["source_repo_root"] = os.path.abspath(source_project_root)
2803 if template_name:
2804 payload["template_name"] = template_name
2805 if template_managed_files is not None:
2806 payload["template_managed_files"] = sorted(set(str(p) for p in template_managed_files))
2807 payload["last_known_source_git_commit"] = get_git_commit(source_project_root)
2808 metadata_path = os.path.join(os.path.abspath(case_dir), CASE_ORIGIN_METADATA_FILENAME)
2809 write_json_file(metadata_path, payload)
2810 return metadata_path, payload
2811
2812
2813#: A `make` variable assignment such as `SYSTEM=cluster` or `CFLAGS+=-g`.
2814_MAKE_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
2815
2816
2817def make_args_include_explicit_goal(make_args: "list[str]") -> bool:
2818 """!
2819 @brief Return True when make args contain an explicit target rather than only options/assignments.
2820 @param[in] make_args Argument passed to `make_args_include_explicit_goal()`.
2821 @return Value returned by `make_args_include_explicit_goal()`.
2822 """
2823 if not make_args:
2824 return False
2825
2826 options_with_value = {
2827 "-C", "-f", "-I", "-j", "-l", "-o", "-W",
2828 "--directory", "--file", "--makefile", "--include-dir", "--jobs",
2829 "--load-average", "--max-load", "--old-file", "--assume-old",
2830 "--what-if", "--new-file", "--assume-new",
2831 }
2832
2833 skip_next = False
2834 for token in make_args:
2835 if skip_next:
2836 skip_next = False
2837 continue
2838 if token in options_with_value:
2839 skip_next = True
2840 continue
2841 if token.startswith("-"):
2842 continue
2843 if _MAKE_ASSIGNMENT_PATTERN.match(token):
2844 continue
2845 return True
2846 return False
2847
2848
2849def resolve_case_origin_context(case_dir_hint: str = None, source_root_override: str = None, template_name_override: str = None):
2850 """!
2851 @brief Resolve case directory, source repo root, and optional template metadata.
2852 @param[in] case_dir_hint Argument passed to `resolve_case_origin_context()`.
2853 @param[in] source_root_override Argument passed to `resolve_case_origin_context()`.
2854 @param[in] template_name_override Argument passed to `resolve_case_origin_context()`.
2855 @return Value returned by `resolve_case_origin_context()`.
2856 """
2857 metadata_case_dir, metadata_path, metadata = load_case_origin_metadata(case_dir_hint)
2858
2859 if metadata_case_dir:
2860 case_dir = metadata_case_dir
2861 else:
2862 case_dir = os.path.abspath(case_dir_hint or os.getcwd())
2863
2864 source_project_root = source_root_override
2865 if source_project_root:
2866 source_project_root = os.path.abspath(source_project_root)
2867 elif isinstance(metadata, dict) and isinstance(metadata.get("source_repo_root"), str):
2868 source_project_root = os.path.abspath(metadata["source_repo_root"])
2869 else:
2870 source_project_root = discover_local_project_root(case_dir)
2871
2872 template_name = template_name_override
2873 if not template_name and isinstance(metadata, dict):
2874 template_name = metadata.get("template_name")
2875
2876 return {
2877 "case_dir": case_dir,
2878 "metadata_path": metadata_path,
2879 "metadata": metadata or {},
2880 "source_project_root": source_project_root,
2881 "template_name": template_name,
2882 }
2883
2884
2885def require_project_root(candidate: str, purpose: str):
2886 """!
2887 @brief Validate that a source repo root was resolved and is structurally valid.
2888 @param[in] candidate Argument passed to `require_project_root()`.
2889 @param[in] purpose Argument passed to `require_project_root()`.
2890 @return Value returned by `require_project_root()`.
2891 """
2892 if not candidate:
2893 raise ValueError(
2894 f"Could not determine the PICurv source repository for {purpose}. "
2895 "Run this command from an initialized case directory or pass --source-root."
2896 )
2897 candidate_abs = os.path.abspath(candidate)
2898 if not is_project_root(candidate_abs):
2899 raise ValueError(
2900 f"Resolved source repository for {purpose} is not a valid PICurv root: {candidate_abs}"
2901 )
2902 return candidate_abs
2903
2904
2905def require_existing_case_dir(case_dir: str, purpose: str, source_project_root: str = None):
2906 """!
2907 @brief Validate that a target case directory exists and is not the source repo root.
2908 @param[in] case_dir Argument passed to `require_existing_case_dir()`.
2909 @param[in] purpose Argument passed to `require_existing_case_dir()`.
2910 @param[in] source_project_root Argument passed to `require_existing_case_dir()`.
2911 @return Value returned by `require_existing_case_dir()`.
2912 """
2913 if not case_dir:
2914 raise ValueError(f"Could not determine the case directory for {purpose}. Pass --case-dir.")
2915 case_dir_abs = os.path.abspath(case_dir)
2916 if not os.path.isdir(case_dir_abs):
2917 raise ValueError(f"Case directory for {purpose} does not exist: {case_dir_abs}")
2918 if source_project_root and os.path.abspath(source_project_root) == case_dir_abs:
2919 raise ValueError(
2920 f"Refusing to run {purpose} against the source repository root itself: {case_dir_abs}"
2921 )
2922 return case_dir_abs
2923
2924
2925def resolve_template_directory(source_project_root: str, template_name: str):
2926 """!
2927 @brief Resolve an example template directory inside the source repository.
2928 @param[in] source_project_root Argument passed to `resolve_template_directory()`.
2929 @param[in] template_name Argument passed to `resolve_template_directory()`.
2930 @return Value returned by `resolve_template_directory()`.
2931 """
2932 if not template_name:
2933 raise ValueError(
2934 "Template name is required for config sync. Re-run with --template-name or from a case initialized by current picurv."
2935 )
2936 template_dir = os.path.join(source_project_root, "examples", template_name)
2937 if not os.path.isdir(template_dir):
2938 raise ValueError(f"Case template '{template_name}' not found at '{template_dir}'")
2939 return template_dir
2940
2941
2942def list_template_relative_files(template_dir: str, excluded_rel_paths=None):
2943 """!
2944 @brief List all files in a template directory as case-relative paths.
2945 @param[in] template_dir Argument passed to `list_template_relative_files()`.
2946 @param[in] excluded_rel_paths Argument passed to `list_template_relative_files()`.
2947 @return Value returned by `list_template_relative_files()`.
2948 """
2949 template_dir_abs = os.path.abspath(template_dir)
2950 if not os.path.isdir(template_dir_abs):
2951 raise ValueError(f"Template directory not found: {template_dir_abs}")
2952 excluded = set(excluded_rel_paths or [])
2953 relative_paths = []
2954 for root, _, files in os.walk(template_dir_abs):
2955 rel_root = os.path.relpath(root, template_dir_abs)
2956 for filename in sorted(files):
2957 rel_path = filename if rel_root == "." else os.path.join(rel_root, filename)
2958 if rel_path in excluded:
2959 continue
2960 relative_paths.append(rel_path)
2961 return relative_paths
2962
2963
2964def render_template_workspace(template_dir: str, template_name: str, destination: str) -> dict:
2965 """!
2966 @brief Render an example template into the canonical workspace layout at a new path.
2967 @details This is the one transformation `init` applies to a template: copy it, drop the
2968 execution example, then relocate and rewrite its files through
2969 organize_initialized_workspace(). `sync-config` and `status-source` render the
2970 current template the same way, so all three agree on where each file lives and
2971 what it contains.
2972 @param[in] template_dir Example template directory to render.
2973 @param[in] template_name Example template identity.
2974 @param[in] destination Path to render into; it must not exist.
2975 @return The layout mapping returned by organize_initialized_workspace().
2976 """
2977 shutil.copytree(template_dir, destination)
2978 copied_runtime_example = os.path.join(destination, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
2979 if os.path.isfile(copied_runtime_example):
2980 os.remove(copied_runtime_example)
2981 return organize_initialized_workspace(destination, template_name, source_template_root=template_dir)
2982
2983
2984def list_rendered_template_files(rendered_root: str) -> list:
2985 """!
2986 @brief List the template-managed files of a rendered workspace, workspace-relative.
2987 @details The workspace identity file is excluded: it records when and where the
2988 workspace was created, which is the workspace's own state, not the template's.
2989 @param[in] rendered_root Root produced by render_template_workspace().
2990 @return Sorted workspace-relative paths.
2991 """
2992 return sorted(
2993 rel for rel in list_template_relative_files(rendered_root)
2994 if rel != WORKSPACE_CONFIG_FILENAME
2995 )
2996
2997
2998def list_source_binaries(source_project_root: str):
2999 """!
3000 @brief List binary artifacts currently available in the source repo bin directory.
3001 @param[in] source_project_root Argument passed to `list_source_binaries()`.
3002 @return Value returned by `list_source_binaries()`.
3003 """
3004 source_bin_dir = os.path.join(os.path.abspath(source_project_root), "bin")
3005 if not os.path.isdir(source_bin_dir):
3006 raise ValueError(f"Source bin directory not found: {source_bin_dir}. Run 'picurv build' first.")
3007 binaries = sorted(
3008 f for f in os.listdir(source_bin_dir)
3009 if os.path.isfile(os.path.join(source_bin_dir, f)) and f != "picurv"
3010 )
3011 if not binaries:
3012 raise ValueError(f"Source bin directory contains no files: {source_bin_dir}")
3013 return source_bin_dir, binaries
3014
3015
3016def sync_case_binaries(case_dir: str, source_project_root: str):
3017 """!
3018 @brief Copy current source-repo binaries into a case directory for version-pinning.
3019 @param[in] case_dir Argument passed to `sync_case_binaries()`.
3020 @param[in] source_project_root Argument passed to `sync_case_binaries()`.
3021 @return Value returned by `sync_case_binaries()`.
3022 """
3023 case_dir_abs = os.path.abspath(case_dir)
3024 os.makedirs(case_dir_abs, exist_ok=True)
3025 source_bin_dir, binaries = list_source_binaries(source_project_root)
3026 copied = []
3027 for binary_name in binaries:
3028 source_path = os.path.join(source_bin_dir, binary_name)
3029 dest_path = os.path.join(case_dir_abs, binary_name)
3030 shutil.copy2(source_path, dest_path)
3031 copied.append(dest_path)
3032 return copied
3033
3034
3035def sync_case_template_files(case_dir: str, template_dir: str, overwrite: bool = False,
3036 prune: bool = False, managed_rel_paths=None, template_name: str = None):
3037 """!
3038 @brief Sync template files into a case directory, preserving modified files unless overwrite is requested.
3039 @param[in] case_dir Argument passed to `sync_case_template_files()`.
3040 @param[in] template_dir Argument passed to `sync_case_template_files()`.
3041 @param[in] overwrite Argument passed to `sync_case_template_files()`.
3042 @param[in] prune Argument passed to `sync_case_template_files()`.
3043 @param[in] managed_rel_paths Argument passed to `sync_case_template_files()`.
3044 @param[in] template_name Template identity; defaults to the template directory name.
3045 @return Value returned by `sync_case_template_files()`.
3046 @details Files are compared with a fresh rendering of the template in the workspace
3047 layout `init` creates, so a case file is matched with the template file it
3048 was made from, under its workspace name and rewritten contents.
3049 """
3050 case_dir_abs = os.path.abspath(case_dir)
3051 template_dir_abs = os.path.abspath(template_dir)
3052 if not os.path.isdir(template_dir_abs):
3053 raise ValueError(f"Template directory not found: {template_dir_abs}")
3054 template_name = template_name or os.path.basename(template_dir_abs)
3055
3056 summary = {
3057 "copied": [],
3058 "overwritten": [],
3059 "skipped_modified": [],
3060 "unchanged": [],
3061 "pruned": [],
3062 "prune_requested_without_tracking": False,
3063 }
3064 with tempfile.TemporaryDirectory(prefix="picurv-template-") as scratch:
3065 rendered_root = os.path.join(scratch, os.path.basename(case_dir_abs) or "workspace")
3066 render_template_workspace(template_dir_abs, template_name, rendered_root)
3067 current_template_files = list_rendered_template_files(rendered_root)
3068 for rel_path in current_template_files:
3069 src_path = os.path.join(rendered_root, rel_path)
3070 dest_path = os.path.join(case_dir_abs, rel_path)
3071 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
3072
3073 if not os.path.exists(dest_path):
3074 shutil.copy2(src_path, dest_path)
3075 summary["copied"].append(dest_path)
3076 continue
3077
3078 if filecmp.cmp(src_path, dest_path, shallow=False):
3079 summary["unchanged"].append(dest_path)
3080 continue
3081
3082 if overwrite:
3083 shutil.copy2(src_path, dest_path)
3084 summary["overwritten"].append(dest_path)
3085 else:
3086 summary["skipped_modified"].append(dest_path)
3087 current_template_set = set(current_template_files)
3088
3089 managed_set = set(managed_rel_paths or [])
3090 if prune:
3091 if not managed_set:
3092 summary["prune_requested_without_tracking"] = True
3093 for rel_path in sorted(managed_set - current_template_set):
3094 dest_path = os.path.join(case_dir_abs, rel_path)
3095 if os.path.isfile(dest_path):
3096 os.remove(dest_path)
3097 summary["pruned"].append(dest_path)
3098
3099 summary["template_managed_files"] = current_template_files
3100 return summary
3101
3102
3103def compute_case_source_status(case_dir: str, source_project_root: str, template_name: str = None, metadata: dict = None):
3104 """!
3105 @brief Compute source/case drift across commits, binaries, and template-managed files.
3106 @param[in] case_dir Argument passed to `compute_case_source_status()`.
3107 @param[in] source_project_root Argument passed to `compute_case_source_status()`.
3108 @param[in] template_name Argument passed to `compute_case_source_status()`.
3109 @param[in] metadata Argument passed to `compute_case_source_status()`.
3110 @return Value returned by `compute_case_source_status()`.
3111 """
3112 case_dir_abs = os.path.abspath(case_dir)
3113 source_root_abs = os.path.abspath(source_project_root)
3114 metadata = metadata or {}
3115 status = {
3116 "case_dir": case_dir_abs,
3117 "source_repo_root": source_root_abs,
3118 "metadata_present": bool(metadata),
3119 "template_name": template_name,
3120 "last_known_source_git_commit": metadata.get("last_known_source_git_commit"),
3121 "current_source_git_commit": get_git_commit(source_root_abs),
3122 }
3123 status["source_commit_changed"] = (
3124 bool(status["last_known_source_git_commit"])
3125 and bool(status["current_source_git_commit"])
3126 and status["last_known_source_git_commit"] != status["current_source_git_commit"]
3127 )
3128
3129 binary_status = {
3130 "source_bin_present": False,
3131 "source_bin_missing": [],
3132 "case_bin_missing": [],
3133 "case_bin_different": [],
3134 "case_bin_current": [],
3135 }
3136 try:
3137 source_bin_dir, binaries = list_source_binaries(source_root_abs)
3138 binary_status["source_bin_present"] = True
3139 for binary_name in binaries:
3140 source_path = os.path.join(source_bin_dir, binary_name)
3141 case_path = os.path.join(case_dir_abs, binary_name)
3142 if not os.path.isfile(case_path):
3143 binary_status["case_bin_missing"].append(binary_name)
3144 elif filecmp.cmp(source_path, case_path, shallow=False):
3145 binary_status["case_bin_current"].append(binary_name)
3146 else:
3147 binary_status["case_bin_different"].append(binary_name)
3148 except ValueError as exc:
3149 binary_status["source_bin_missing"].append(str(exc))
3150 status["binaries"] = binary_status
3151
3152 config_status = {
3153 "template_available": False,
3154 "template_files": [],
3155 "case_missing_files": [],
3156 "case_modified_files": [],
3157 "case_current_files": [],
3158 "template_removed_since_last_sync": [],
3159 "tracking_available": isinstance(metadata.get("template_managed_files"), list),
3160 }
3161 if template_name:
3162 try:
3163 template_dir = resolve_template_directory(source_root_abs, template_name)
3164 with tempfile.TemporaryDirectory(prefix="picurv-template-") as scratch:
3165 rendered_root = os.path.join(scratch, os.path.basename(case_dir_abs) or "workspace")
3166 render_template_workspace(template_dir, template_name, rendered_root)
3167 template_files = list_rendered_template_files(rendered_root)
3168 config_status["template_available"] = True
3169 config_status["template_files"] = template_files
3170 for rel_path in template_files:
3171 src_path = os.path.join(rendered_root, rel_path)
3172 case_path = os.path.join(case_dir_abs, rel_path)
3173 if not os.path.isfile(case_path):
3174 config_status["case_missing_files"].append(rel_path)
3175 elif filecmp.cmp(src_path, case_path, shallow=False):
3176 config_status["case_current_files"].append(rel_path)
3177 else:
3178 config_status["case_modified_files"].append(rel_path)
3179 managed_files = metadata.get("template_managed_files")
3180 if isinstance(managed_files, list):
3181 config_status["template_removed_since_last_sync"] = sorted(set(managed_files) - set(template_files))
3182 except ValueError:
3183 pass
3184 status["config"] = config_status
3185
3186 case_runtime_cfg = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
3187 repo_runtime_seed = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
3188 runtime_status = {
3189 "case_config_present": os.path.isfile(case_runtime_cfg),
3190 "repo_seed_present": os.path.isfile(repo_runtime_seed),
3191 "case_matches_repo_seed": False,
3192 }
3193 if runtime_status["case_config_present"] and runtime_status["repo_seed_present"]:
3194 runtime_status["case_matches_repo_seed"] = filecmp.cmp(
3195 case_runtime_cfg,
3196 repo_runtime_seed,
3197 shallow=False,
3198 )
3199 status["runtime_execution"] = runtime_status
3200 return status
3201
3202
3204 """!
3205 @brief Render human-readable source/case drift details.
3206 @param[in] status Argument passed to `print_case_source_status()`.
3207 """
3208 print(f"[INFO] Case directory : {status['case_dir']}")
3209 print(f"[INFO] Source repo : {status['source_repo_root']}")
3210 print(f"[INFO] Template : {status.get('template_name') or '(unknown)'}")
3211 if status.get("last_known_source_git_commit"):
3212 print(f"[INFO] Last synced commit : {status['last_known_source_git_commit']}")
3213 if status.get("current_source_git_commit"):
3214 print(f"[INFO] Current src commit : {status['current_source_git_commit']}")
3215 print(f"[INFO] Source changed : {'yes' if status.get('source_commit_changed') else 'no'}")
3216
3217 binaries = status["binaries"]
3218 if binaries["source_bin_present"] and not (binaries["case_bin_current"] or binaries["case_bin_different"]):
3219 print("[INFO] Binaries : not pinned (the installation's executables are used)")
3220 elif binaries["source_bin_present"]:
3221 print(
3222 f"[INFO] Binaries : current={len(binaries['case_bin_current'])} "
3223 f"changed={len(binaries['case_bin_different'])} missing={len(binaries['case_bin_missing'])}"
3224 )
3225 else:
3226 print("[INFO] Binaries : source bin/ unavailable")
3227
3228 config = status["config"]
3229 if config["template_available"]:
3230 print(
3231 f"[INFO] Template files : current={len(config['case_current_files'])} "
3232 f"modified={len(config['case_modified_files'])} missing={len(config['case_missing_files'])}"
3233 )
3234 if config["tracking_available"]:
3235 print(f"[INFO] Prune candidates : {len(config['template_removed_since_last_sync'])}")
3236 else:
3237 print("[INFO] Prune candidates : tracking unavailable")
3238 elif status.get("template_name"):
3239 print("[INFO] Template files : template unavailable in source repo")
3240
3241 runtime_cfg = status.get("runtime_execution", {})
3242 print(
3243 f"[INFO] Runtime config : case={'yes' if runtime_cfg.get('case_config_present') else 'no'} "
3244 f"repo-seed={'yes' if runtime_cfg.get('repo_seed_present') else 'no'} "
3245 f"matches-repo-seed={'yes' if runtime_cfg.get('case_matches_repo_seed') else 'no'}"
3246 )
3247
3248
3250 """!
3251 @brief Report source/case drift for an initialized case directory.
3252 @param[in] args Command-line style argument list supplied to the function.
3253 """
3254 try:
3256 case_dir_hint=getattr(args, "case_dir", None),
3257 source_root_override=getattr(args, "source_root", None),
3258 template_name_override=getattr(args, "template_name", None),
3259 )
3260 source_project_root = require_project_root(context["source_project_root"], "status-source")
3261 case_dir = require_existing_case_dir(context["case_dir"], "status-source", source_project_root)
3263 case_dir,
3264 source_project_root,
3265 template_name=context.get("template_name"),
3266 metadata=context.get("metadata"),
3267 )
3268 except ValueError as exc:
3269 print(f"[FATAL] {exc}", file=sys.stderr)
3270 sys.exit(1)
3271
3272 if getattr(args, "output_format", "text") == "json":
3273 print(json.dumps(status, indent=2, sort_keys=True))
3274 return
3276
3277def resolve_path(anchor_file: str, candidate: str) -> str:
3278 """!
3279 @brief Resolve a potentially relative path against a source YAML file path.
3280 @param[in] anchor_file Argument passed to `resolve_path()`.
3281 @param[in] candidate Argument passed to `resolve_path()`.
3282 @return Value returned by `resolve_path()`.
3283 """
3284 if candidate is None:
3285 return None
3286 return resolve_workspace_path(anchor_file, candidate)
3287
3288
3289POST_RUN_CONTROL_ALIASES = {
3290 "start_step": ("start_step", "startTime"),
3291 "end_step": ("end_step", "endTime"),
3292 "step_interval": ("step_interval", "timeStep"),
3293}
3294
3295
3296GRID_GENERATOR_HYPHEN_KEY_HINTS = {
3297 "config-file": "config_file",
3298 "grid-type": "grid_type",
3299 "cli-args": "cli_args",
3300}
3301
3302#: Generator keys that used to name their own output destinations. PICurv owns where
3303#: generated artifacts go, so accepting these would let a configuration file create a
3304#: competing output directory outside the asset store.
3305RETIRED_GENERATOR_DESTINATION_KEYS = ("output_file", "stats_file", "vts_file",
3306 "output-file", "stats-file", "vts-file")
3307
3308
3309def _mapping_value_with_aliases(mapping: dict, *keys, default=None):
3310 """!
3311 @brief Return the first defined value from a mapping across alias keys.
3312 @param[in] mapping Argument passed to `_mapping_value_with_aliases()`.
3313 @param[in] default Argument passed to `_mapping_value_with_aliases()`.
3314 @param[in] keys Argument passed to `_mapping_value_with_aliases()`.
3315 @return Value returned by `_mapping_value_with_aliases()`.
3316 """
3317 if not isinstance(mapping, dict):
3318 return default
3319 for key in keys:
3320 if key in mapping:
3321 return mapping.get(key)
3322 return default
3323
3324
3325def get_post_run_control_value(post_cfg: dict, canonical_key: str, default=None):
3326 """!
3327 @brief Resolve post run_control values with backwards-compatible legacy aliases.
3328 @param[in] post_cfg Argument passed to `get_post_run_control_value()`.
3329 @param[in] canonical_key Argument passed to `get_post_run_control_value()`.
3330 @param[in] default Argument passed to `get_post_run_control_value()`.
3331 @return Value returned by `get_post_run_control_value()`.
3332 """
3333 aliases = POST_RUN_CONTROL_ALIASES.get(canonical_key, (canonical_key,))
3334 rc = post_cfg.get("run_control", {})
3335 return _mapping_value_with_aliases(rc, *aliases, default=default)
3336
3337
3338def warn_on_grid_generator_hyphen_keys(generator: dict, case_path: str, warnings: list) -> None:
3339 """!
3340 @brief Warn when grid.generator uses unsupported hyphenated wrapper keys.
3341 @param[in] generator grid.generator mapping from case.yml.
3342 @param[in] case_path Case file path for diagnostics.
3343 @param[in,out] warnings Warning list to append to.
3344 """
3345 if not isinstance(generator, dict):
3346 return
3347 for bad_key, expected_key in GRID_GENERATOR_HYPHEN_KEY_HINTS.items():
3348 if bad_key in generator and bad_key != expected_key:
3349 warnings.append(
3350 f"{case_path}: grid.generator.{bad_key} is ignored; use grid.generator.{expected_key}."
3351 )
3352
3353
3354#: Destination keys a generated initial condition once accepted. Its payload, summary and
3355#: spectrum always land at fixed paths in the asset store, so these were read and ignored.
3356RETIRED_INITIAL_CONDITION_DESTINATION_KEYS = ("output_file", "summary_json", "spectrum_csv")
3357
3358
3359def retired_destination_message(label: str, key: str) -> str:
3360 """!
3361 @brief Explain why a generator may not choose its own output destination.
3362 @param[in] label Dotted configuration path of the mapping carrying the key.
3363 @param[in] key The refused destination key.
3364 @return The refusal message, shared by every generator that once took one.
3365 """
3366 return (
3367 f"'{label}.{key}' is no longer accepted. PICurv chooses where generated artifacts "
3368 "go; the published asset carries the payload, its preview, and its validation record."
3369 )
3370
3371
3372def reject_generator_destination_keys(generator, case_path: str, label: str) -> list:
3373 """!
3374 @brief Reject generator settings that try to choose their own output destination.
3375 @param[in] generator Generator mapping from the case configuration.
3376 @param[in] case_path Case file path for diagnostics.
3377 @param[in] label Dotted configuration path being checked, for the message.
3378 @return List of error strings.
3379 """
3380 if not isinstance(generator, dict):
3381 return []
3382 return [
3383 f" {case_path}: {retired_destination_message(label, key)}"
3384 for key in RETIRED_GENERATOR_DESTINATION_KEYS if key in generator
3385 ]
3386
3387
3388def get_post_source_data(post_cfg: dict):
3389 """!
3390 @brief Return source_data as a mapping when valid, else an empty mapping.
3391 @param[in] post_cfg Argument passed to `get_post_source_data()`.
3392 @return Value returned by `get_post_source_data()`.
3393 """
3394 source_cfg = post_cfg.get("source_data", {})
3395 if isinstance(source_cfg, dict):
3396 return source_cfg
3397 return {}
3398
3399
3400def get_post_source_directory_template(post_cfg: dict, default: str = "<solver_output_dir>") -> str:
3401 """!
3402 @brief Resolve the source directory template from source_data with a safe default.
3403 @param[in] post_cfg Argument passed to `get_post_source_directory_template()`.
3404 @param[in] default Argument passed to `get_post_source_directory_template()`.
3405 @return Value returned by `get_post_source_directory_template()`.
3406 """
3407 return get_post_source_data(post_cfg).get("directory", default)
3408
3409
3410def get_post_input_extensions(post_cfg: dict):
3411 """!
3412 @brief Return post input_extensions, preferring io.* and tolerating legacy source_data.* placement.
3413 @param[in] post_cfg Argument passed to `get_post_input_extensions()`.
3414 @return Value returned by `get_post_input_extensions()`.
3415 """
3416 io_cfg = post_cfg.get("io", {})
3417 io_ext = io_cfg.get("input_extensions") if isinstance(io_cfg, dict) else None
3418 if isinstance(io_ext, dict):
3419 return io_ext
3420
3421 source_ext = get_post_source_data(post_cfg).get("input_extensions")
3422 if isinstance(source_ext, dict):
3423 return source_ext
3424
3425 return {}
3426
3427
3429 """!
3430 @brief Return normalized statistics pipeline tokens that will be written into post.run.
3431 @param[in] post_cfg Argument passed to `get_post_statistics_task_tokens()`.
3432 @return Value returned by `get_post_statistics_task_tokens()`.
3433 """
3434 stats_cfg = post_cfg.get("statistics_pipeline")
3435 stats_entries = []
3436 if isinstance(stats_cfg, list):
3437 stats_entries = stats_cfg
3438 elif isinstance(stats_cfg, dict):
3439 stats_entries = stats_cfg.get("tasks", [])
3440
3441 tokens = []
3442 for entry in stats_entries:
3443 if isinstance(entry, str):
3444 task_name = entry
3445 elif isinstance(entry, dict):
3446 task_name = entry.get("task")
3447 else:
3448 continue
3449 try:
3450 tokens.append(normalize_statistics_task(task_name))
3451 except ValueError:
3452 continue
3453 return tokens
3454
3455
3456def get_monitor_output_directory(monitor_cfg: dict, default: str = "output") -> str:
3457 """!
3458 @brief Resolve the solver output root from monitor.yml, preserving the default layout.
3459 @param[in] monitor_cfg Argument passed to `get_monitor_output_directory()`.
3460 @param[in] default Argument passed to `get_monitor_output_directory()`.
3461 @return Value returned by `get_monitor_output_directory()`.
3462 """
3463 del monitor_cfg, default
3464 return CANONICAL_RUN_PATHS["output"]
3465
3466
3467def get_post_statistics_output_prefix(post_cfg: dict, default: str = "Stats") -> str:
3468 """!
3469 @brief Resolve the statistics CSV prefix, preserving legacy top-level override support.
3470 @param[in] post_cfg Argument passed to `get_post_statistics_output_prefix()`.
3471 @param[in] default Argument passed to `get_post_statistics_output_prefix()`.
3472 @return Value returned by `get_post_statistics_output_prefix()`.
3473 """
3474 stats_cfg = post_cfg.get("statistics_pipeline")
3475 if isinstance(stats_cfg, dict):
3476 prefix = stats_cfg.get("output_prefix")
3477 if isinstance(prefix, str) and prefix.strip():
3478 return prefix.strip()
3479
3480 legacy_prefix = post_cfg.get("statistics_output_prefix")
3481 if isinstance(legacy_prefix, str) and legacy_prefix.strip():
3482 return legacy_prefix.strip()
3483
3484 return default
3485
3486
3487def resolve_post_statistics_output_prefix(post_cfg: dict, monitor_cfg=None, default: str = "Stats") -> str:
3488 """!
3489 @brief Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
3490 @param[in] post_cfg Argument passed to `resolve_post_statistics_output_prefix()`.
3491 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
3492 @param[in] default Argument passed to `resolve_post_statistics_output_prefix()`.
3493 @return Value returned by `resolve_post_statistics_output_prefix()`.
3494 """
3495 prefix = get_post_statistics_output_prefix(post_cfg, default=default).strip()
3496 if os.path.isabs(prefix):
3497 return prefix
3498
3499 if os.path.dirname(prefix):
3500 return prefix
3501
3502 del monitor_cfg
3503 return os.path.join(CANONICAL_RUN_PATHS["statistics"], prefix)
3504
3505
3506def get_post_statistics_output_artifacts(post_cfg: dict, run_dir: str, monitor_cfg=None):
3507 """!
3508 @brief Predict statistics CSV output paths relative to the postprocessor runtime cwd.
3509 @param[in] post_cfg Argument passed to `get_post_statistics_output_artifacts()`.
3510 @param[in] run_dir Argument passed to `get_post_statistics_output_artifacts()`.
3511 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
3512 @return Value returned by `get_post_statistics_output_artifacts()`.
3513 """
3514 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
3515 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
3516 token_to_suffix = {
3517 "ComputeMSD": "_msd.csv",
3518 }
3519 prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
3520 if os.path.isabs(prefix):
3521 base_path = os.path.abspath(prefix)
3522 else:
3523 base_path = os.path.abspath(os.path.join(run_dir, prefix))
3524
3525 output_paths = []
3526 for token in get_post_statistics_task_tokens(post_cfg):
3527 suffix = token_to_suffix.get(token)
3528 if suffix:
3529 output_paths.append(base_path + suffix)
3530
3531 return list(dict.fromkeys(output_paths))
3532
3533
3534#: Spectra tasks a post recipe may request, with the preconditions each needs.
3535#:
3536#: `requires_uniform_cartesian` is checked against the staged PICGRID and
3537#: `requires_periodic_geometric` against the resolved boundary conditions, both
3538#: before any field is read. A task whose preconditions the case cannot meet is
3539#: refused at validation rather than producing a curve with no meaning: a spectrum
3540#: needs the transform direction to be uniformly spaced, periodic, and statistically
3541#: homogeneous. Wall-bounded Cartesian cases may transform only their periodic axes.
3542POST_SPECTRA_TASKS = {
3543 "plane_spectrum": {"requires_single_block": True, "fields": ("Ucat",)},
3544 "line_spectrum": {"requires_single_block": True, "fields": ("Ucat",)},
3545 "shell_spectrum": {
3546 "requires_uniform_cartesian": True,
3547 "requires_periodic_geometric": True,
3548 "requires_single_block": True,
3549 "fields": ("Ucat",),
3550 },
3551}
3552
3553#: Binning abscissae a spectra task may request.
3554POST_SPECTRA_SYMBOLS = ("continuum", "discrete")
3555
3556#: Fluctuation definitions a spectra task may request. `window:<name>` is accepted
3557#: as a prefixed form and resolved against the accumulated windows.
3558POST_SPECTRA_MEAN_MODES = ("none", "domain", "sample")
3559
3560
3561#: Derived outputs a post recipe may request from an accumulated window.
3562POST_FIELD_STATISTICS_OUTPUTS = ("mean", "reynolds_stress", "rms", "tke", "flux", "point_variance")
3563#: Outputs written when a recipe names none: every output except the opt-in `point_variance`,
3564#: which in a profile answers a different question from `rms` (page 10, profiles).
3565POST_FIELD_STATISTICS_DEFAULT_OUTPUTS = POST_FIELD_STATISTICS_OUTPUTS[:-1]
3566
3567#: Output formats a post recipe may request. `vtk` writes the derived fields; `csv`
3568#: appends one convergence row per processed step.
3569POST_FIELD_STATISTICS_FORMATS = ("vtk", "csv")
3570
3571# ---------------------------------------------------------------------------
3572# Authoritative public choice sets.
3573#
3574# Each of these was a literal written inline at its point of use, which made it
3575# invisible to the capability census: a set nobody can enumerate is a set nobody can
3576# document. Naming them here is the discovery contract - the census enumerates
3577# module-level names with these suffixes and demands a classification for each.
3578# ---------------------------------------------------------------------------
3579
3580#: How the grid reaches the solver.
3581GRID_MODES = ("file", "programmatic_c", "grid_gen")
3582
3583#: Flow dimensionality. `2D` does not change the grid: it zeroes the i-direction row of
3584#: the momentum right-hand side (`-TwoD 1`), so the i velocity component never evolves.
3585DOMAIN_DIMENSIONALITY_MODES = ("3D", "2D")
3586
3587#: Geometries the bundled grid generator can produce.
3588GRID_GENERATOR_TYPES = ("box", "sweep")
3589
3590#: Cross-sections a swept duct may carry. `circle` is the square-to-disc map, not an
3591#: O-grid: `Metric.c`'s `cgrid` branch for a circumferential seam is reachable only via
3592#: `programmatic_c`'s `cgrids` flag, and even there `src/grid.c` never builds anything
3593#: but a Cartesian box, so no path produces real O-grid coordinates to seam.
3594GRID_CROSS_SECTION_KINDS = ("rectangle", "circle")
3595
3596#: Segment kinds a piecewise wall-height field accepts. A wall is built from these end to
3597#: end, so a step is one entry in a list rather than a geometry of its own.
3598GRID_WALL_SEGMENT_KINDS = ("flat", "step", "ramp", "arc", "sine", "gaussian", "hill")
3599
3600#: Centreline segment kinds a swept path accepts.
3601GRID_PATH_SEGMENT_KINDS = ("straight", "arc")
3602
3603#: Placement and similarity operations applied after a geometry map.
3604GRID_TRANSFORM_KINDS = ("anchor", "translate", "scale", "rotate", "mirror", "permute", "reverse")
3605
3606#: Whether a run seeds particles afresh or restores them from a checkpoint.
3607PARTICLE_RESTART_MODES = ("init", "load")
3608
3609#: Eulerian post-processing kernels selectable per pipeline entry.
3610POST_EULERIAN_PIPELINE_TASKS = ("q_criterion", "normalize_field", "nodal_average")
3611
3612#: Lagrangian post-processing kernels selectable per pipeline entry.
3613POST_LAGRANGIAN_PIPELINE_TASKS = ("specific_ke",)
3614
3615#: Study shapes `picurv sweep` knows how to expand and aggregate.
3616STUDY_TYPES = ("grid_independence", "timestep_independence", "sensitivity")
3617
3618#: Preconditioning models for the Newton-Krylov momentum solve.
3619NEWTON_KRYLOV_PRECONDITIONER_MODELS = ("none", "frozen_momentum_jacobian")
3620
3621#: Matrix structures a Newton-Krylov preconditioner model may assemble. Determined by
3622#: the model rather than chosen independently: `frozen_momentum_jacobian` requires
3623#: `point_block`, and `none` accepts no structure at all.
3624NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES = ("none", "point_block")
3625
3626#: Outer preconditioners the Poisson solve accepts. One canonical value today; the
3627#: census fails if this grows without a capability family being registered for it.
3628POISSON_PRECONDITIONER_TYPES = ("multigrid",)
3629
3630#: Accepted spellings for the Poisson outer preconditioner.
3631POISSON_PRECONDITIONER_SPELLINGS = {"mg": "multigrid", "pcmg": "multigrid"}
3632
3633# Storage compression policies live in picurv_cli/storage.py, which owns them; core
3634# imports storage, so declaring them here would be a cycle.
3635
3636#: How a prescribed Eulerian field is supplied.
3637PRESCRIBED_FLOW_SOURCE_TYPES = ("file", "generated", "field_slice")
3638
3639#: Analytic scalar profiles the verification source may generate.
3640VERIFICATION_SCALAR_PROFILES = ("CONSTANT", "LINEAR_X", "SIN_PRODUCT")
3641
3642#: Scalar-transport parameters and the runtime flag each becomes. The Schmidt numbers are
3643#: positive and set particle diffusivity; `iem_constant` is C_IEM in the IEM mixing rate
3644#: Omega = C_IEM Gamma / Delta^2 (runtime default 2.0), and 0 switches micromixing off so a
3645#: particle scalar is a passive label.
3646SCALAR_TRANSPORT_FLAGS = {
3647 "schmidt_number": "-schmidt_number",
3648 "turbulent_schmidt_number": "-turb_schmidt_number",
3649 "iem_constant": "-iem_constant",
3650}
3651
3652#: Where each scalar-transport parameter lives in case.yml: the molecular Schmidt number is a
3653#: fluid property, the turbulent one a parameter of the subgrid model, and C_IEM a parameter
3654#: of the particle mixing model. solver.yml `scalar_transport` is their deprecated location.
3655SCALAR_TRANSPORT_CASE_PATHS = {
3656 "schmidt_number": ("properties", "fluid"),
3657 "turbulent_schmidt_number": ("models", "physics", "turbulence"),
3658 "iem_constant": ("models", "physics", "particles"),
3659}
3660
3661
3662def resolve_scalar_transport(case_cfg: dict, solver_cfg: dict, warn: bool = False) -> dict:
3663 """!
3664 @brief Resolve the scalar-transport parameters from case.yml or their deprecated location.
3665 @details Each parameter is read from its case.yml location (SCALAR_TRANSPORT_CASE_PATHS)
3666 or, as a deprecated alias, from solver.yml `scalar_transport`. Setting one in both
3667 places is refused rather than resolved by precedence.
3668 @param[in] case_cfg Parsed case configuration.
3669 @param[in] solver_cfg Parsed solver configuration.
3670 @param[in] warn Print a deprecation warning for each parameter read from solver.yml.
3671 @return Mapping of parameter name to validated value, for the parameters that are set.
3672 @throws ValueError for a malformed, duplicated, or out-of-range parameter.
3673 """
3674 legacy = (solver_cfg or {}).get("scalar_transport") or {}
3675 if not isinstance(legacy, dict):
3676 raise ValueError("solver.yml scalar_transport must be a mapping when provided.")
3677 unknown = sorted(set(legacy) - set(SCALAR_TRANSPORT_FLAGS))
3678 if unknown:
3679 raise ValueError(f"solver.yml scalar_transport has unsupported key(s): {unknown}. "
3680 f"Use one of {sorted(SCALAR_TRANSPORT_FLAGS)}, in case.yml.")
3681 values = {}
3682 for key, path in SCALAR_TRANSPORT_CASE_PATHS.items():
3683 node = case_cfg or {}
3684 for part in path:
3685 node = node.get(part) if isinstance(node, dict) else None
3686 case_label = ".".join(path + (key,))
3687 in_case = isinstance(node, dict) and key in node
3688 if in_case and key in legacy:
3689 raise ValueError(f"{key} is set in case.yml ({case_label}) and in its deprecated "
3690 f"solver.yml location (scalar_transport.{key}); keep only the case.yml one.")
3691 if not in_case and key not in legacy:
3692 continue
3693 raw, label = (node[key], f"case.yml {case_label}") if in_case else \
3694 (legacy[key], f"solver.yml scalar_transport.{key}")
3695 try:
3696 value = _to_finite_float(raw, label) if key == "iem_constant" else float(raw)
3697 except (TypeError, ValueError) as exc:
3698 raise ValueError(f"{label} must be numeric.") from exc
3699 # iem_constant = 0 switches micromixing off; the Schmidt numbers divide.
3700 if value < 0.0 or (value == 0.0 and key != "iem_constant"):
3701 raise ValueError(f"{label} must be {'non-negative' if key == 'iem_constant' else 'positive'}.")
3702 if not in_case and warn:
3703 print(f"[WARNING] solver.yml scalar_transport.{key} is deprecated; set it in case.yml "
3704 f"as {case_label}.", file=sys.stderr)
3705 values[key] = value
3706 return values
3707
3708#: Image formats `picurv sweep` can render study plots in.
3709STUDY_PLOT_FORMATS = ("png", "pdf", "svg")
3710
3711#: How much per-timestep profiling output the monitor emits.
3712PROFILING_TIMESTEP_MODES = ("off", "selected", "all")
3713
3714#: Solver functions renamed by the Poisson rewrite (0d34592). Function lists in monitor.yml
3715#: match names exactly, so an old name would select nothing; it is translated instead.
3716RENAMED_MONITOR_FUNCTIONS = {
3717 "PoissonSolver_MG": "PoissonSolver_Multigrid",
3718 "Projection": "ProjectVelocity",
3719 "PoissonLHSNew": "AssemblePoissonOperator",
3720 "PoissonRHS": "ComputePoissonRHS",
3721}
3722
3723#: Solver functions the Poisson rewrite removed; nothing logs or profiles under these names.
3724REMOVED_MONITOR_FUNCTIONS = (
3725 "CorrectChannelFluxProfile", "VolumeFlux", "VolumeFlux_rev", "FullyBlocked",
3726 "MyNvertRestriction", "MyRestriction", "MyInterpolation", "PoissonNullSpaceFunction",
3727 "GridRestriction", "SetupSolverParameters",
3728)
3729
3730#: Krylov methods for which a `gmres.restart` parameter is meaningful.
3731GMRES_RESTART_METHODS = ("gmres", "fgmres", "lgmres")
3732
3733#: Outer Poisson Krylov methods verified against the multigrid preconditioner, with the
3734#: PETSc options that make each stop on the true residual. The preconditioner is not a
3735#: single fixed linear operator, which only a flexible method tolerates: under gmres,
3736#: lgmres and bcgs - left- or right-preconditioned - the Krylov residual fell to 1e-12
3737#: while the true residual stalled near 1e-3 and the projection left a divergence of 2e-4.
3738#: CG keeps its left preconditioner and is told to monitor the unpreconditioned norm. Any
3739#: other KSP type is reachable only through petsc_passthrough_options, unverified.
3740POISSON_KSP_METHODS = {
3741 "fgmres": {},
3742 "cg": {"-ps_ksp_norm_type": "unpreconditioned"},
3743}
3744
3745#: Methods refused with a specific reason rather than the generic unverified message.
3746POISSON_KSP_REFUSED = {
3747 "gmres": "is not flexible",
3748 "lgmres": "is not flexible",
3749 "bcgs": "is not flexible",
3750}
3751
3752#: Analytical solution types the Eulerian source can impose.
3753ANALYTICAL_SOLUTION_TYPES = ("TGV3D", "ZERO_FLOW", "UNIFORM_FLOW")
3754
3755#: Legacy capitalised spellings of the field-initialisation mode, still accepted and
3756#: resolved through normalize_field_init_mode.
3757LEGACY_FIELD_INIT_SPELLINGS = ("Zero", "Constant", "Poiseuille")
3758
3759#: Discrete operators the solenoidal projection may use.
3760PROJECTION_OPERATORS = ("continuum", "picurv_discrete")
3761
3762#: Where `picurv sweep` reads a declared metric from.
3763METRIC_SOURCE_KINDS = ("statistics_csv", "csv", "log_regex", "log")
3764
3765#: Where a run is launched.
3766LAUNCH_MODES = ("local", "cluster")
3767
3768
3769
3770def normalize_post_spectra_config(post_cfg: dict) -> dict:
3771 """!
3772 @brief Validate and canonicalize the spectra block of post.yml.
3773
3774 @details The recipe chooses what to measure; whether the case *can* support that
3775 measurement is a separate question answered by
3776 `validate_post_spectra_preconditions()`, which needs the grid and the
3777 boundary conditions. Keeping the two apart means a recipe stays valid on
3778 its own terms even when it is read without a case beside it.
3779
3780 @param[in] post_cfg Parsed post-processing configuration.
3781 @return Normalized mapping with an output prefix and a list of tasks.
3782 @throws ValueError on a malformed or inconsistent recipe.
3783 """
3784 raw = (post_cfg or {}).get("spectra")
3785 if raw is None:
3786 return {"output_prefix": "Spectrum", "tasks": []}
3787 if not isinstance(raw, dict):
3788 raise ValueError("'spectra' must be a mapping.")
3789
3790 prefix = raw.get("output_prefix", "Spectrum")
3791 if not isinstance(prefix, str) or not prefix.strip():
3792 raise ValueError("'spectra.output_prefix' must be a non-empty string.")
3793
3794 tasks = raw.get("tasks")
3795 if not isinstance(tasks, list) or not tasks:
3796 raise ValueError("'spectra.tasks' must be a non-empty list.")
3797
3798 cleaned = []
3799 seen = []
3800 for position, entry in enumerate(tasks):
3801 if not isinstance(entry, dict):
3802 raise ValueError(f"spectra task {position}: each task must be a mapping.")
3803 name = entry.get("task")
3804 if not isinstance(name, str) or name not in POST_SPECTRA_TASKS:
3805 raise ValueError(
3806 f"spectra task {position}: unknown task {name!r}. "
3807 f"Available tasks: {sorted(POST_SPECTRA_TASKS)}."
3808 )
3809 spec = POST_SPECTRA_TASKS[name]
3810
3811 field = entry.get("field", spec["fields"][0])
3812 if field not in spec["fields"]:
3813 raise ValueError(
3814 f"spectra task '{name}': field {field!r} is not supported. "
3815 f"Supported fields: {list(spec['fields'])}."
3816 )
3817
3818 block = entry.get("block", 0)
3819 if not isinstance(block, int) or isinstance(block, bool) or block < 0:
3820 raise ValueError(f"spectra task '{name}': 'block' must be a non-negative integer.")
3821
3822 symbol = entry.get("symbol", "continuum")
3823 if symbol not in POST_SPECTRA_SYMBOLS:
3824 raise ValueError(
3825 f"spectra task '{name}': unknown symbol {symbol!r}. "
3826 f"Available symbols: {list(POST_SPECTRA_SYMBOLS)}."
3827 )
3828
3829 subtract_mean = entry.get("subtract_mean", "none")
3830 if not isinstance(subtract_mean, str) or not subtract_mean.strip():
3831 raise ValueError(f"spectra task '{name}': 'subtract_mean' must be a string.")
3832 subtract_mean = subtract_mean.strip()
3833 if subtract_mean.startswith("window:"):
3834 window_name = subtract_mean.split(":", 1)[1].strip()
3835 if not window_name:
3836 raise ValueError(
3837 f"spectra task '{name}': 'subtract_mean: window:<name>' needs a window name."
3838 )
3839 elif subtract_mean not in POST_SPECTRA_MEAN_MODES:
3840 raise ValueError(
3841 f"spectra task '{name}': unknown subtract_mean {subtract_mean!r}. "
3842 f"Use one of {list(POST_SPECTRA_MEAN_MODES)} or 'window:<name>'."
3843 )
3844
3845 # A window mean is only worth subtracting once the window has converged, so the
3846 # bundle the mean is read from is chosen separately from the step being
3847 # transformed. Left unset, each step uses its own bundle, whose running mean is
3848 # undefined at the moment the window activates.
3849 mean_source_step = entry.get("mean_source_step")
3850 if mean_source_step is not None:
3851 if (not isinstance(mean_source_step, int) or isinstance(mean_source_step, bool)
3852 or mean_source_step < 0):
3853 raise ValueError(
3854 f"spectra task '{name}': 'mean_source_step' must be a non-negative integer."
3855 )
3856 if not subtract_mean.startswith("window:"):
3857 raise ValueError(
3858 f"spectra task '{name}': 'mean_source_step' only applies to "
3859 f"'subtract_mean: window:<name>'."
3860 )
3861
3862 reference = entry.get("reference")
3863 if reference is not None and (not isinstance(reference, str) or not reference.strip()):
3864 raise ValueError(f"spectra task '{name}': 'reference' must be a non-empty string.")
3865
3866 selection = {}
3867 if name != "shell_spectrum":
3868 axes = entry.get("axes")
3869 fixed = entry.get("fixed_indices")
3870 count = 2 if name == "plane_spectrum" else 1
3871 if (not isinstance(axes, list) or len(axes) != count or any(a not in ("i", "j", "k") for a in axes)
3872 or len(set(axes)) != count or not isinstance(fixed, dict)
3873 or set(fixed) != set("ijk")-set(axes)
3874 or any(isinstance(i, bool) or not isinstance(i, int) or i < 0 for i in fixed.values())):
3875 raise ValueError(f"{name} requires {count} distinct axes and zero-based fixed_indices on all remaining axes.")
3876 if symbol != "continuum":
3877 raise ValueError("plane/line spectra require symbol: continuum.")
3878 selection = {"axes": sorted(axes), "fixed_indices": dict(sorted(fixed.items()))}
3879 elif subtract_mean == "sample" or "axes" in entry or "fixed_indices" in entry:
3880 raise ValueError("shell_spectrum does not accept sample subtraction or plane/line selections.")
3881 # Each task writes its own file, so a repeat would overwrite its own output.
3882 identity = (name, field, block, symbol, json.dumps(selection, sort_keys=True))
3883 if identity in seen:
3884 raise ValueError(
3885 f"spectra recipe lists task '{name}' for field {field} on block {block} "
3886 f"with symbol '{symbol}' more than once."
3887 )
3888 seen.append(identity)
3889 cleaned.append({
3890 "task": name,
3891 **selection,
3892 "field": field,
3893 "block": block,
3894 "symbol": symbol,
3895 "subtract_mean": subtract_mean,
3896 "mean_source_step": mean_source_step,
3897 "reference": reference.strip() if isinstance(reference, str) else None,
3898 })
3899
3900 return {"output_prefix": prefix.strip(), "tasks": cleaned}
3901
3902
3903def validate_post_spectra_preconditions(spectra_cfg: dict, case_cfg: dict, post_path: str) -> list:
3904 """!
3905 @brief Check spectra tasks against what the case can actually support.
3906
3907 @details Runs before any field is read, so a case with no homogeneous direction is
3908 refused rather than yielding a curve that means nothing. Only the checks
3909 the case file can answer are made here: periodicity and block count. Grid
3910 uniformity is enforced by `generators/spectra.gen`, which reads the staged
3911 PICGRID and is the only place the real node coordinates are known.
3912
3913 @param[in] spectra_cfg Normalized spectra recipe.
3914 @param[in] case_cfg Parsed case configuration.
3915 @param[in] post_path Post recipe path, for error messages.
3916 @return List of formatted error strings; empty when every task is supportable.
3917 """
3918 errors = []
3919 if not spectra_cfg.get("tasks"):
3920 return errors
3921
3922 try:
3923 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
3924 except ValueError:
3925 # The case file reports its own boundary-condition errors; do not repeat them.
3926 return errors
3927
3928 block_count = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
3929 periodic_faces = {}
3930 for block_index, block_bcs in enumerate(prepared_blocks):
3931 faces = {entry.get("face") for entry in block_bcs if entry.get("type") == "PERIODIC"}
3932 periodic_faces[block_index] = faces
3933
3934 # run_post_spectra_stage reads the staged PICGRID, which only file and grid_gen
3935 # grids provide; refuse here rather than after the solve has run.
3936 grid_mode = str((case_cfg.get("grid", {}) or {}).get("mode", "")).strip()
3937 if grid_mode == "programmatic_c":
3938 errors.append(
3939 f" {post_path}: spectra need a staged PICGRID, which grid.mode 'programmatic_c' "
3940 "does not provide. Use grid.mode 'grid_gen' or 'file' for a case whose spectra "
3941 "are measured."
3942 )
3943 return errors
3944
3945 all_faces = {"-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta"}
3946 for task_cfg in spectra_cfg["tasks"]:
3947 spec = POST_SPECTRA_TASKS[task_cfg["task"]]
3948 label = f"spectra task '{task_cfg['task']}'"
3949
3950 if spec.get("requires_single_block") and block_count != 1:
3951 errors.append(
3952 f" {post_path}: {label} requires a single-block domain; this case has "
3953 f"{block_count} blocks."
3954 )
3955
3956 block = task_cfg["block"]
3957 if block >= block_count:
3958 errors.append(
3959 f" {post_path}: {label} targets block {block}, but the case defines "
3960 f"{block_count} block(s)."
3961 )
3962 continue
3963
3964 if task_cfg["task"] != "shell_spectrum":
3965 if (case_cfg.get("models", {}).get("physics", {}).get("fsi", {}) or {}).get("immersed", False):
3966 errors.append(f" {post_path}: {label} does not support immersed masks.")
3967 names = {"i": "Xi", "j": "Eta", "k": "Zeta"}
3968 needed = {sign+names[a] for a in task_cfg["axes"] for sign in ("-", "+")}
3969 if not needed <= periodic_faces.get(block, set()):
3970 errors.append(f" {post_path}: {label} requires PERIODIC boundaries on its transform axes.")
3971 if spec.get("requires_periodic_geometric"):
3972 missing = sorted(all_faces - periodic_faces.get(block, set()))
3973 if missing:
3974 errors.append(
3975 f" {post_path}: {label} requires every face of block {block} to be "
3976 f"PERIODIC, because a shell-averaged spectrum is only defined for a "
3977 f"triply periodic homogeneous box. Non-periodic faces: {missing}."
3978 )
3979 return errors
3980
3981
3982def _resolve_spectra_payload(bundle: dict, kind: str, field: str, block: int) -> str:
3983 """!
3984 @brief Locate one checkpoint payload by its inventory entry rather than by path shape.
3985 @param[in] bundle Validated checkpoint bundle mapping.
3986 @param[in] kind Payload kind recorded in the inventory.
3987 @param[in] field Payload field name recorded in the inventory.
3988 @param[in] block Block index the payload belongs to.
3989 @return Absolute path to the payload file.
3990 @throws ValueError when the bundle carries no such payload.
3991 """
3992 for payload in bundle["payloads"]:
3993 if (payload["kind"] == kind and payload["field"] == field
3994 and payload["block"] == str(block)):
3995 return os.path.join(bundle["bundle"], *payload["path"].split("/"))
3996 raise ValueError(
3997 f"checkpoint {os.path.basename(bundle['bundle'])} carries no {kind} payload "
3998 f"'{field}' for block {block}."
3999 )
4000
4001
4002def _spectra_mean_arguments(task_cfg: dict, bundle: dict, mean_bundle: dict = None) -> list:
4003 """!
4004 @brief Build the generator arguments implementing a task's fluctuation choice.
4005 @param[in] task_cfg Normalized spectra task.
4006 @param[in] bundle Validated checkpoint bundle for the step being transformed.
4007 @param[in] mean_bundle Bundle supplying the window mean; defaults to @p bundle.
4008 @return Argument list to append to the generator command.
4009 @throws ValueError when a named window is absent from the bundle.
4010 """
4011 mode = task_cfg["subtract_mean"]
4012 if not mode.startswith("window:"):
4013 return ["--subtract-mean", mode]
4014 window = mode.split(":", 1)[1].strip()
4015 block = task_cfg["block"]
4016 source = mean_bundle or bundle
4017 mean_path = _resolve_spectra_payload(source, "mean", f"{window}/{task_cfg['field']}_mean", block)
4018 count_path = _resolve_spectra_payload(source, "occupancy", f"{window}/count", block)
4019 return ["--subtract-mean", "field", "--mean-file", mean_path, "--count-file", count_path]
4020
4021
4022#: Scalar columns the spectra stage records once per processed step.
4023POST_SPECTRA_SCALAR_COLUMNS = (
4024 "resolved_kinetic_energy", "spectrum_total_energy", "parseval_residual",
4025 "spectrum_peak_k", "zero_mode_energy", "integral_length_scale",
4026 "taylor_microscale", "dissipation_over_viscosity",
4027)
4028
4029
4030#: Stages `picurv run --post-process` can execute, in the order they are listed.
4031POST_STAGE_NAMES = ("fields", "spectra", "reducers")
4032
4033
4035 """!
4036 @brief Resolve the `--only` selector into the set of post stages to execute.
4037 @param[in] only Comma-separated selector text, or None for every stage.
4038 @return Set of stage names.
4039 @throws ValueError when the selector names an unknown or empty stage.
4040 """
4041 if not only:
4042 return set(POST_STAGE_NAMES)
4043 requested = [token.strip() for token in str(only).split(",")]
4044 if not all(requested):
4045 raise ValueError("--only must not contain an empty stage name.")
4046 unknown = sorted({token for token in requested if token not in POST_STAGE_NAMES})
4047 if unknown:
4048 raise ValueError(
4049 f"--only names unknown post stage(s) {unknown}. "
4050 f"Available stages: {list(POST_STAGE_NAMES)}."
4051 )
4052 return set(requested)
4053
4054
4055def run_post_spectra_stage(run_dir: str, post_cfg: dict, monitor_cfg: dict,
4056 source_dir: str, steps, quiet: bool = False,
4057 plan: "dict | None" = None, recompute: bool = False) -> dict:
4058 """!
4059 @brief Measure spectra for every requested task across a window of committed steps.
4060
4061 @details One spectrum per step per task, because a spectrum is a property of one
4062 state: averaging across steps would be wrong for a decaying flow, where
4063 every step is a different statistical state. Results are written in long
4064 form so a family of curves stays one file, alongside a scalar history that
4065 plots through the ordinary series machinery.
4066
4067 @param[in] run_dir Run directory receiving the output.
4068 @param[in] post_cfg Parsed post-processing configuration.
4069 @param[in] monitor_cfg Parsed monitor configuration anchoring the output root.
4070 @param[in] source_dir Directory holding the committed checkpoints.
4071 @param[in] steps Iterable of checkpoint steps to process, in order.
4072 @param[in] quiet Suppress progress reporting.
4073 @param[in] plan Post plan whose recipe state records each measured step; with it, a
4074 step whose rows are still valid (classify_post_step()) is not
4075 measured again. None measures every committed step given.
4076 @param[in] recompute Measure every committed step even when its rows are valid.
4077 @return Summary with the written paths and the steps actually processed.
4078 @throws ValueError when the generator fails or a requested payload is absent.
4079 """
4080 spectra = normalize_post_spectra_config(post_cfg)
4081 if not spectra["tasks"]:
4082 return {"tasks": [], "steps": [], "artifacts": []}
4083
4084 script = os.path.join(GENERATORS_PATH, "spectra.gen")
4085 if not os.path.isfile(script):
4086 raise ValueError(f"spectra.gen script not found: {script}")
4087 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
4088 if not os.path.isfile(staged_grid):
4089 raise ValueError(f"spectra require a staged PICGRID at {staged_grid}.")
4090
4091 output_dir = os.path.join(
4092 os.path.abspath(run_dir), resolve_recipe_spectra_output_dir(post_cfg, monitor_cfg)
4093 )
4094 os.makedirs(output_dir, exist_ok=True)
4095
4096 # Spectra are computed from the staged non-dimensional field on the staged
4097 # non-dimensional grid, so physical units are the generator's to apply. The run's
4098 # own configuration snapshot is the authority for the scales it used.
4099 scale_arguments = []
4100 # Checkpoint times are solver time; a dimensionalized result reports physical time.
4101 time_scale = 1.0
4102 if bool((post_cfg.get("global_operations") or {}).get("dimensionalize", False)):
4103 active_case = load_active_run_configuration(run_dir).get("case")
4104 case_path = os.path.join(run_dir, active_case) if active_case else None
4105 if case_path and os.path.isfile(case_path):
4106 scaling = resolve_fluid_scaling(read_yaml_file(case_path))
4107 scale_arguments = [
4108 "--velocity-ref", repr(float(scaling["velocity_ref"])),
4109 "--length-ref", repr(float(scaling["length_ref"])),
4110 ]
4111 time_scale = reference_scale(TIME, scaling)
4112 elif not quiet:
4113 print("[WARNING] Spectra: dimensionalize was requested but this run carries no "
4114 "readable case snapshot; results stay non-dimensional.", file=sys.stderr)
4115
4116 requested = sorted(set(int(step) for step in steps))
4117 signature = compute_post_spectra_signature(spectra)
4118 records = {}
4119 if plan is not None:
4120 # Steps whose rows are still valid are not measured again; unrecorded rows are
4121 # adopted on the same terms as field output (classify_post_step()).
4122 existing = _read_post_records(plan['resume_state_path'])[1]
4123 # Each task writes a spectrum and a history file; a step is measured only when
4124 # both of every task's files hold it.
4125 spectra_files = [
4126 os.path.join(output_dir, f"{post_spectra_task_basename(task, spectra['output_prefix'])}{suffix}.csv")
4127 for task in spectra["tasks"] for suffix in ("", "_history")
4128 ]
4129 measured = set.intersection(*(_scan_post_statistics_csv_steps(path) for path in spectra_files))
4130 pending = []
4131 for step in requested:
4132 record = existing.get(str(step))
4133 commit = _checkpoint_commit(source_dir, step)
4134 reason = classify_post_step(record, spectra_files if step in measured else None,
4135 commit, signature, recompute)
4136 if reason is not None:
4137 pending.append(step)
4138 elif record is None:
4139 records[step] = {"recipe": signature, "checkpoint": commit[0] if commit else None}
4140 if not quiet and len(pending) != len(requested):
4141 print(f"[INFO] Spectra: {len(requested) - len(pending)} requested step(s) are up to date.")
4142 try:
4143 require_storage_payload_local(run_dir, "spectra", checkpoints=pending)
4144 except StorageError as exc:
4145 raise ValueError(str(exc)) from exc
4146 requested = pending
4147 if not requested:
4148 persist_post_resume_state(run_dir, plan, spectra=records)
4149 return {"tasks": [], "steps": [], "artifacts": []}
4150 # The requested window is what the recipe asks for, not what exists. Like the field
4151 # stage, only committed checkpoints are measured: a window reaching past the last
4152 # committed checkpoint is normal while a solve is still running, and is not an error.
4153 available = _scan_committed_checkpoint_steps(source_dir)
4154 ordered_steps = [step for step in requested if step in available]
4155 if not ordered_steps:
4156 if not quiet:
4157 print("[INFO] Spectra: no step needing measurement has a committed checkpoint "
4158 "yet; nothing measured.")
4159 if plan is not None:
4160 persist_post_resume_state(run_dir, plan, spectra=records)
4161 return {"tasks": [], "steps": [], "artifacts": []}
4162 if not quiet and len(ordered_steps) != len(requested):
4163 print(f"[INFO] Spectra: {len(ordered_steps)} of {len(requested)} step(s) needing "
4164 f"measurement are committed; measuring those.")
4165 artifacts = []
4166 for task_cfg in spectra["tasks"]:
4167 basename = post_spectra_task_basename(task_cfg, spectra["output_prefix"])
4168 spectrum_path = os.path.join(output_dir, f"{basename}.csv")
4169 scalar_path = os.path.join(output_dir, f"{basename}_history.csv")
4170 spectrum_rows = []
4171 scalar_rows = []
4172 mean_bundle = None
4173 if task_cfg["mean_source_step"] is not None:
4174 mean_bundle = validate_committed_checkpoint(source_dir, task_cfg["mean_source_step"])
4175
4176 for step in ordered_steps:
4177 bundle = validate_committed_checkpoint(source_dir, step)
4178 time = float(bundle["metadata"]["checkpoint_time"]) * time_scale
4179 field_path = _resolve_spectra_payload(
4180 bundle, "eulerian", task_cfg["field"], task_cfg["block"]
4181 )
4182 cmd = [sys.executable, script, task_cfg["task"].replace("_", "-"),
4183 "--field-file", field_path, "--source-grid", staged_grid,
4184 "--block", str(task_cfg["block"]), "--symbol", task_cfg["symbol"]]
4185 if task_cfg["task"] != "shell_spectrum":
4186 cmd.extend(["--axes", *task_cfg["axes"], "--fixed-indices", json.dumps(task_cfg["fixed_indices"])])
4187 cmd.extend(_spectra_mean_arguments(task_cfg, bundle, mean_bundle))
4188 cmd.extend(scale_arguments)
4189 cmd.extend(petsc_index_arguments(int(bundle["metadata"].get("checkpoint_petsc_index_bytes", "4"))))
4190 result = subprocess.run(cmd, text=True, capture_output=True)
4191 if result.returncode != 0:
4192 details = (result.stderr or result.stdout or "").strip()
4193 raise ValueError(
4194 f"spectra task '{task_cfg['task']}' failed at step {step} with exit code "
4195 f"{result.returncode}. Details:\n{details}"
4196 )
4197 summary = json.loads(result.stdout)
4198 for row in summary.get("sampled_spectrum", summary.get("shell_spectrum")):
4199 spectrum_rows.append({"step": step, "time": time, **row})
4200 scalar_columns = (POST_SPECTRA_SCALAR_COLUMNS if task_cfg["task"] == "shell_spectrum"
4201 else ("resolved_kinetic_energy", "parseval_residual"))
4202 scalar_rows.append({"step": step, "time": time,
4203 **{name: summary[name] for name in scalar_columns}})
4204
4205 spectrum_fields = ("step", "time", *summary.get("spectrum_columns", ("k", "energy")))
4206 scalar_fields = ("step", "time") + scalar_columns
4207 # Only the steps that were missing or stale are measured; keep the rows already
4208 # written for every other step, replacing only those re-measured now.
4209 remeasured = set(ordered_steps)
4210 spectrum_rows = _merge_spectra_rows(spectrum_path, spectrum_fields, remeasured, spectrum_rows)
4211 scalar_rows = _merge_spectra_rows(scalar_path, scalar_fields, remeasured, scalar_rows)
4212 with open(spectrum_path, "w", newline="", encoding="utf-8") as stream:
4213 writer = csv.DictWriter(stream, fieldnames=spectrum_fields)
4214 writer.writeheader()
4215 writer.writerows(spectrum_rows)
4216 with open(scalar_path, "w", newline="", encoding="utf-8") as stream:
4217 writer = csv.DictWriter(stream, fieldnames=scalar_fields)
4218 writer.writeheader()
4219 writer.writerows(scalar_rows)
4220 artifacts.extend([spectrum_path, scalar_path])
4221 if not quiet:
4222 print(f"[INFO] Spectra: measured {len(ordered_steps)} step(s) for task "
4223 f"'{task_cfg['task']}'; {os.path.relpath(spectrum_path, run_dir)} now holds "
4224 f"{len(scalar_rows)} step(s)")
4225
4226 if plan is not None:
4227 for step in ordered_steps:
4228 commit = _checkpoint_commit(source_dir, step)
4229 records[step] = {"recipe": signature, "checkpoint": commit[0] if commit else None}
4230 persist_post_resume_state(run_dir, plan, spectra=records)
4231 return {
4232 "tasks": [entry["task"] for entry in spectra["tasks"]],
4233 "steps": ordered_steps,
4234 "artifacts": artifacts,
4235 }
4236
4237
4238#: Reducer tasks a post recipe may request. Each runs a `generators/reduce.gen`
4239#: subcommand over the field-statistics profiles of one window or ensemble.
4240#: `retains` is how many directions the profile must keep, and `axis_key` the task key
4241#: naming the one it keeps (`wall_axis`) or the one it merged (`stream_axis`).
4242POST_REDUCER_TASKS = {
4243 "wall_profile": {"command": "wall-profile", "retains": 1, "axis_key": "wall_axis", "default_axis": "j"},
4244 "cross_section": {"command": "cross-section", "retains": 2, "axis_key": "stream_axis", "default_axis": "k"},
4245 "profile_series": {"command": "profile-series", "retains": 1, "axis_key": None, "default_axis": None},
4246}
4247
4248#: Where a wall reducer takes its friction velocity from, and what each needs: a metrics
4249#: source averages over the window's effective bounds, so it needs a run window rather
4250#: than an ensemble; a direct source takes the recipe's `value`.
4251POST_REDUCER_FRICTION_SOURCES = {
4252 "first_cell": {"needs": None},
4253 "driven_flow": {"needs": "window"},
4254 "wall_model": {"needs": "window"},
4255 "body_force": {"needs": "value"},
4256 "u_tau": {"needs": "value"},
4257}
4258
4259#: Index axis -> the axis token `reduce.gen` takes.
4260_REDUCER_AXIS_TOKENS = {"i": "Xi", "j": "Eta", "k": "Zeta"}
4261
4262
4263def normalize_post_reducers_config(post_cfg: dict) -> dict:
4264 """!
4265 @brief Validate and canonicalize the reducers block of post.yml.
4266 @details A reducer reads the profiles `field_statistics` writes, so each task names a
4267 window or ensemble of the same recipe, and the recipe's `homogeneous` must
4268 leave the directions the task needs.
4269 @param[in] post_cfg Parsed post-processing configuration.
4270 @return Mapping with the canonical task list.
4271 """
4272 raw = (post_cfg or {}).get("reducers")
4273 if raw is None:
4274 return {"tasks": []}
4275 if not isinstance(raw, dict) or not isinstance(raw.get("tasks"), list) or not raw["tasks"]:
4276 raise ValueError("'reducers' must be a mapping with a non-empty 'tasks' list.")
4277 statistics = normalize_post_field_statistics_config(post_cfg)
4278 windows = set(statistics["windows"])
4279 owners = windows | {ensemble["name"] for ensemble in statistics["ensembles"]}
4280 retained = [axis for axis in ("i", "j", "k") if axis not in statistics["homogeneous"]]
4281 tasks = []
4282 for index, entry in enumerate(raw["tasks"]):
4283 if not isinstance(entry, dict):
4284 raise ValueError(f"'reducers.tasks[{index}]' must be a mapping.")
4285 name = entry.get("task")
4286 if not isinstance(name, str) or name not in POST_REDUCER_TASKS:
4287 raise ValueError(f"reducer task {name!r} is unknown. Available tasks: {sorted(POST_REDUCER_TASKS)}.")
4288 spec = POST_REDUCER_TASKS[name]
4289 label = f"reducer '{name}'"
4290 source = entry.get("source")
4291 if not isinstance(source, str) or source not in owners:
4292 raise ValueError(f"{label}: 'source' must name a field_statistics window or ensemble of this "
4293 f"recipe (got {source!r}; available: {sorted(owners)}).")
4294 if "csv" not in statistics["formats"] or len(retained) != spec["retains"]:
4295 raise ValueError(f"{label} reads a profile keeping {spec['retains']} direction(s), so "
4296 f"field_statistics needs 'csv' in formats and 'homogeneous' leaving that "
4297 f"many (it leaves {retained}).")
4298 task = {"task": name, "source": source}
4299 if spec["axis_key"]:
4300 axis = entry.get(spec["axis_key"], spec["default_axis"])
4301 if axis not in _REDUCER_AXIS_TOKENS:
4302 raise ValueError(f"{label}: '{spec['axis_key']}' must be one of i, j, k (got {axis!r}).")
4303 kept = axis in retained
4304 if kept != (spec["axis_key"] == "wall_axis"):
4305 raise ValueError(f"{label}: the profile {'must keep' if not kept else 'must merge over'} "
4306 f"'{axis}', but 'homogeneous' is {statistics['homogeneous']}.")
4307 task[spec["axis_key"]] = axis
4308 stream = entry.get("stream_axis", "k")
4309 if stream not in _REDUCER_AXIS_TOKENS or stream == task.get("wall_axis"):
4310 raise ValueError(f"{label}: 'stream_axis' must be i, j or k and differ from the wall axis.")
4311 task["stream_axis"] = stream
4312 friction = entry.get("friction_velocity", "first_cell")
4313 if friction not in POST_REDUCER_FRICTION_SOURCES or (name == "cross_section" and friction == "wall_model"):
4314 raise ValueError(f"{label}: 'friction_velocity' must be one of "
4315 f"{[f for f in POST_REDUCER_FRICTION_SOURCES if name != 'cross_section' or f != 'wall_model']} "
4316 f"(got {friction!r}).")
4317 needs = POST_REDUCER_FRICTION_SOURCES.get(friction, {}).get("needs")
4318 if needs == "window" and source not in windows:
4319 raise ValueError(f"{label}: friction_velocity '{friction}' averages over a run window's time "
4320 f"bounds, so 'source' must be a run window, not an ensemble.")
4321 task["friction_velocity"] = friction
4322 if needs == "value":
4323 value = entry.get("value")
4324 if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
4325 raise ValueError(f"{label}: friction_velocity '{friction}' needs a positive 'value'.")
4326 task["value"] = float(value)
4327 if name == "cross_section":
4328 task["fold"] = bool(entry.get("fold", True))
4329 if name == "wall_profile" and entry.get("half_height") is not None:
4330 task["half_height"] = float(entry["half_height"])
4331 tasks.append(task)
4332 return {"tasks": tasks}
4333
4334
4335def run_post_reducers_stage(run_dir: str, post_cfg: dict, quiet: bool = False) -> list:
4336 """!
4337 @brief Run every reducer task over the profiles the recipe has written.
4338 @details Reducers read only small CSVs, so every profile present is reduced again on
4339 each invocation rather than tracked step by step. Inputs come from the run:
4340 the viscosity and reference scales from its case snapshot, the grid it was
4341 staged with, a window's effective bounds from each step's `checkpoint.meta`,
4342 and the runtime metrics CSVs.
4343 @param[in] run_dir Run directory.
4344 @param[in] post_cfg Post recipe with canonical paths applied.
4345 @param[in] quiet Suppress progress reporting.
4346 @return Absolute paths written.
4347 @throws ValueError when a reducer fails.
4348 """
4349 tasks = normalize_post_reducers_config(post_cfg)["tasks"]
4350 if not tasks:
4351 return []
4352 run_dir = os.path.abspath(run_dir)
4353 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
4354 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
4355 script = os.path.join(GENERATORS_PATH, "reduce.gen")
4356 grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
4357 case_path = load_active_run_configuration(run_dir).get("case")
4358 case_cfg = read_yaml_file(case_path) if case_path and os.path.isfile(case_path) else {}
4359 scaling = resolve_fluid_scaling(case_cfg)
4360 dimensional = bool((post_cfg.get("global_operations") or {}).get("dimensionalize", False))
4361 velocity_scale = scaling["velocity_ref"] if dimensional else 1.0
4362 dt_physical = float((case_cfg.get("run_control") or {}).get("dt_physical", 0.0) or 0.0)
4363 step_time = dt_physical if dimensional else dt_physical / reference_scale(TIME, scaling)
4364 prefix = os.path.join(run_dir, post_cfg["_picurv_paths"]["field_statistics_prefix"])
4365 output_root = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
4366 lineage = resolve_run_lineage(run_dir)
4367 written = []
4368
4369 def lineage_metrics(name):
4370 """!
4371 @brief List a metrics CSV across the restart lineage, root to leaf.
4372 @details A window carried into a branched run spans its ancestors' time, whose
4373 metrics rows are in their own files. Each ancestor's file is limited to
4374 the steps up to the checkpoint its child branched from.
4375 @param[in] name Metrics CSV basename.
4376 @return `path` or `path@last_step` entries for every segment holding the file.
4377 """
4378 entries = []
4379 for index, segment in enumerate(lineage):
4380 path = os.path.join(segment["run_dir"], CANONICAL_RUN_PATHS["metrics"], name)
4381 if not os.path.isfile(path):
4382 continue
4383 child = lineage[index + 1] if index + 1 < len(lineage) else None
4384 entries.append(f"{path}@{child['fork_step']}" if child else path)
4385 return entries or [os.path.join(run_dir, CANONICAL_RUN_PATHS["metrics"], name)]
4386
4387 def run(arguments, label):
4388 """!
4389 @brief Run one reduce.gen subcommand, failing with its own diagnostics.
4390 @param[in] arguments Subcommand and its arguments.
4391 @param[in] label Reducer and step named in a failure.
4392 """
4393 result = subprocess.run([sys.executable, script, *arguments], text=True, capture_output=True)
4394 if result.returncode != 0:
4395 raise ValueError(f"reducer {label} failed with exit code {result.returncode}:\n"
4396 f"{(result.stderr or result.stdout).strip()}")
4397
4398 for task in tasks:
4399 family = f"{prefix}_profile_{task['source']}"
4400 if task["task"] == "profile_series":
4401 output = f"{family}_history.csv"
4402 if _scan_post_vtk_steps(family, "csv"):
4403 run(["profile-series", "--profile-prefix", family, "--dt", repr(step_time),
4404 "--output", output], f"profile_series of '{task['source']}'")
4405 written.append(output)
4406 continue
4407 friction = {"first_cell": [], "body_force": ["--body-force", repr(task.get("value"))],
4408 "u_tau": ["--u-tau", repr(task.get("value"))],
4409 "driven_flow": ["--driven-flow-csv", *lineage_metrics("driven_flow.csv")],
4410 "wall_model": ["--wall-model-csv", *lineage_metrics("wall_model.csv")]}[task["friction_velocity"]]
4411 for step, path in sorted(_scan_post_vtk_steps(family, "csv").items()):
4412 arguments = [POST_REDUCER_TASKS[task["task"]]["command"], "--profile", path, "--grid", grid,
4413 "--window", task["source"], "--viscosity",
4414 repr(scaling["nondimensional_kinematic_viscosity"]),
4415 "--velocity-scale", repr(velocity_scale),
4416 "--stream-axis", _REDUCER_AXIS_TOKENS[task["stream_axis"]], *friction]
4417 metadata = os.path.join(_checkpoint_bundle_path(output_root, step), "checkpoint.meta")
4418 options = _read_checkpoint_options(metadata) if os.path.isfile(metadata) else {}
4419 for index in range(int(options.get("checkpoint_statistics_window_count", 0) or 0)):
4420 key = f"checkpoint_statistics_window_{index}_"
4421 if options.get(key + "name") == task["source"]:
4422 arguments += ["--window-start", options[key + "effective_start"],
4423 "--window-end", options[key + "effective_end"],
4424 "--window-note", f"{options.get(key + 'state')}, "
4425 f"{options.get(key + 'sample_count')} samples"]
4426 if task["task"] == "wall_profile":
4427 output = f"{prefix}_wall_profile_{task['source']}_{step}.csv"
4428 arguments += ["--wall-axis", _REDUCER_AXIS_TOKENS[task["wall_axis"]], "--output", output]
4429 if "half_height" in task:
4430 arguments += ["--half-height", repr(task["half_height"])]
4431 written.append(output)
4432 else:
4433 output = f"{prefix}_cross_section_{task['source']}_{step}"
4434 arguments += ["--output-prefix", output] + ([] if task["fold"] else ["--no-fold"])
4435 written.append(output + "_cross_section.csv")
4436 run(arguments, f"{task['task']} of '{task['source']}' at step {step}")
4437 if not quiet:
4438 for path in written:
4439 print(f"[INFO] Reducer output: {os.path.relpath(path)}")
4440 return written
4441
4442
4443def _merge_spectra_rows(path: str, fieldnames: tuple, remeasured: set, new_rows: list) -> list:
4444 """!
4445 @brief Combine newly measured spectra rows with those already written for other steps.
4446 @details The file belongs to one recipe, whose identity covers the spectra settings, so
4447 its existing rows come from the same measurement. Rows for a re-measured step
4448 are replaced; rows for every other step are kept. A file whose columns differ
4449 is not merged: it was written by another format and is rewritten whole.
4450 @param[in] path Existing CSV path, which may be absent.
4451 @param[in] fieldnames Columns the new rows carry.
4452 @param[in] remeasured Steps measured in this invocation.
4453 @param[in] new_rows Rows measured in this invocation.
4454 @return All rows, ordered by step with each step's rows in their measured order.
4455 """
4456 kept = []
4457 try:
4458 with open(path, newline="", encoding="utf-8") as stream:
4459 reader = csv.DictReader(stream)
4460 if tuple(reader.fieldnames or ()) == tuple(fieldnames):
4461 kept = [row for row in reader if int(row["step"]) not in remeasured]
4462 except FileNotFoundError:
4463 pass
4464 return sorted(kept + list(new_rows), key=lambda row: int(row["step"]))
4465
4466
4467def compute_post_spectra_signature(spectra_cfg: dict) -> str:
4468 """!
4469 @brief Reduce a normalized spectra recipe to a stable identity string.
4470 @param[in] spectra_cfg Normalized spectra recipe.
4471 @return Hex digest covering every choice that changes the produced spectra.
4472 """
4473 payload = json.dumps(spectra_cfg, sort_keys=True, separators=(",", ":")).encode("utf-8")
4474 return hashlib.sha256(payload).hexdigest()[:16]
4475
4476
4477def resolve_post_spectra_output_dir(monitor_cfg=None) -> str:
4478 """!
4479 @brief Resolve the run-relative directory spectra CSVs are written to.
4480 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
4481 @return Run-relative directory path.
4482 """
4483 del monitor_cfg
4484 return CANONICAL_RUN_PATHS["spectra"]
4485
4486
4487def resolve_recipe_spectra_output_dir(post_cfg: dict, monitor_cfg=None) -> str:
4488 """!
4489 @brief Resolve the canonical spectra directory for one versioned recipe.
4490 @param[in] post_cfg Parsed runtime post config.
4491 @param[in] monitor_cfg Optional monitor config for standalone compatibility.
4492 @return Run-relative spectra directory.
4493 """
4494 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
4495 return internal.get("spectra") or resolve_post_spectra_output_dir(monitor_cfg)
4496
4497
4498def get_post_spectra_output_artifacts(post_cfg: dict, run_dir: str, monitor_cfg=None) -> list:
4499 """!
4500 @brief Predict the spectra CSV paths a recipe will write.
4501 @param[in] post_cfg Parsed post-processing configuration.
4502 @param[in] run_dir Run directory the recipe operates on.
4503 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
4504 @return Absolute CSV paths, one per task, in recipe order.
4505 """
4506 try:
4507 spectra = normalize_post_spectra_config(post_cfg)
4508 except ValueError:
4509 # The recipe reports its own errors during validation; predict nothing here.
4510 return []
4511 if not spectra["tasks"]:
4512 return []
4513 base = os.path.join(os.path.abspath(run_dir), resolve_recipe_spectra_output_dir(post_cfg, monitor_cfg))
4514 paths = []
4515 for task_cfg in spectra["tasks"]:
4516 name = post_spectra_task_basename(task_cfg, spectra["output_prefix"])
4517 paths.append(os.path.join(base, f"{name}.csv"))
4518 return list(dict.fromkeys(paths))
4519
4520
4521def post_spectra_task_basename(task_cfg: dict, output_prefix: str) -> str:
4522 """!
4523 @brief Build the file basename one normalized spectra task writes.
4524 @param[in] task_cfg Normalized task mapping.
4525 @param[in] output_prefix Recipe output prefix.
4526 @return Basename without directory or extension.
4527 """
4528 parts = [output_prefix, task_cfg["task"], task_cfg["field"],
4529 f"block{task_cfg['block']:04d}", task_cfg["symbol"]]
4530 if task_cfg["task"] != "shell_spectrum":
4531 parts.extend(["".join(task_cfg["axes"]), *[f"{a}{i}" for a,i in task_cfg["fixed_indices"].items()]])
4532 return "_".join(parts)
4533
4534
4536 """!
4537 @brief Validate and canonicalize the ensembles a post recipe defines.
4538 @details An ensemble is a window over one instant: its samples are the particles in
4539 each cell, or each cell's own state, at the step being processed. It is
4540 defined in the recipe rather than the run, because it needs no state
4541 accumulated during the solve.
4542 @param[in] raw The recipe's `field_statistics.ensembles` list, or None.
4543 @return List of ensembles with name, source, fields and covariances.
4544 """
4545 if raw is None:
4546 return []
4547 if not isinstance(raw, list):
4548 raise ValueError("'field_statistics.ensembles' must be a list.")
4549 ensembles = []
4550 for index, entry in enumerate(raw):
4551 if not isinstance(entry, dict):
4552 raise ValueError(f"'field_statistics.ensembles[{index}]' must be a mapping.")
4553 name = entry.get("name")
4554 if not isinstance(name, str) or not name.strip():
4555 raise ValueError(f"'field_statistics.ensembles[{index}]' needs a non-empty 'name'.")
4556 name = name.strip()
4557 if any(existing["name"] == name for existing in ensembles):
4558 raise ValueError(f"field statistics ensemble '{name}' is defined more than once.")
4559 source = entry.get("source")
4560 if source not in POST_STATISTICS_ENSEMBLE_SOURCES:
4561 raise ValueError(
4562 f"field statistics ensemble '{name}': 'source' must be one of "
4563 f"{list(POST_STATISTICS_ENSEMBLE_SOURCES)} (got {source!r})."
4564 )
4565 fields, covariances = _normalize_statistics_requests(
4566 f"field statistics ensemble '{name}'", entry.get("fields"), entry.get("covariances"),
4568 ensembles.append({"name": name, "source": source, "fields": fields, "covariances": covariances})
4569 return ensembles
4570
4571
4572def normalize_post_field_statistics_config(post_cfg: dict) -> dict:
4573 """!
4574 @brief Validate and canonicalize the field_statistics block of post.yml.
4575
4576 @details The recipe names windows rather than redescribing them, because the
4577 window definitions already reach the post-processor through the run's
4578 solver control. Validation therefore covers the recipe's own choices
4579 only; a name no window matches is caught in C against the resolved list,
4580 which is the only place the real set is known.
4581
4582 @param[in] post_cfg Parsed post-processing configuration.
4583 @return Value returned by `normalize_post_field_statistics_config()`.
4584 """
4585 raw = (post_cfg or {}).get("field_statistics")
4586 if raw is None:
4587 return {"windows": [], "ensembles": [], "homogeneous": [], "source_step": None,
4588 "outputs": [], "formats": []}
4589 if not isinstance(raw, dict):
4590 raise ValueError("'field_statistics' must be a mapping.")
4591
4592 ensembles = _normalize_post_statistics_ensembles(raw.get("ensembles"))
4593 windows = raw.get("windows", [] if ensembles else None)
4594 if not isinstance(windows, list) or not (windows or ensembles):
4595 raise ValueError("'field_statistics.windows' must be a non-empty list of window names "
4596 "unless 'ensembles' defines what to derive.")
4597 cleaned = []
4598 for entry in windows:
4599 if not isinstance(entry, str) or not entry.strip():
4600 raise ValueError("'field_statistics.windows' entries must be non-empty window names.")
4601 name = entry.strip()
4602 # Each window writes its own file, so a repeat would overwrite its own output.
4603 if name in cleaned:
4604 raise ValueError(f"field statistics recipe lists window '{name}' more than once.")
4605 cleaned.append(name)
4606 for ensemble in ensembles:
4607 if ensemble["name"] in cleaned:
4608 raise ValueError(f"field statistics ensemble '{ensemble['name']}' has the name of a "
4609 "requested window; both would write the same files.")
4610
4611 label = ", ".join(cleaned + [ensemble["name"] for ensemble in ensembles])
4612 source_step = raw.get("source_step")
4613 if source_step is not None:
4614 if not isinstance(source_step, int) or isinstance(source_step, bool) or source_step < 0:
4615 raise ValueError(
4616 f"field statistics recipe for [{label}]: 'source_step' must be a non-negative "
4617 f"integer (got {source_step!r})."
4618 )
4619
4620 outputs = raw.get("outputs", list(POST_FIELD_STATISTICS_DEFAULT_OUTPUTS))
4621 if not isinstance(outputs, list) or not outputs:
4622 raise ValueError(f"field statistics recipe for [{label}]: 'outputs' must be a non-empty list.")
4623 unknown = [item for item in outputs if item not in POST_FIELD_STATISTICS_OUTPUTS]
4624 if unknown:
4625 raise ValueError(
4626 f"field statistics recipe for [{label}]: unknown outputs {unknown}. "
4627 f"Available outputs: {list(POST_FIELD_STATISTICS_OUTPUTS)}."
4628 )
4629 if len(set(outputs)) != len(outputs):
4630 raise ValueError(f"field statistics recipe for [{label}]: 'outputs' lists a duplicate.")
4631
4632 formats = raw.get("formats", ["vtk"])
4633 if not isinstance(formats, list) or not formats:
4634 raise ValueError(f"field statistics recipe for [{label}]: 'formats' must be a non-empty list.")
4635 unknown = [item for item in formats if item not in POST_FIELD_STATISTICS_FORMATS]
4636 if unknown:
4637 raise ValueError(
4638 f"field statistics recipe for [{label}]: unknown formats {unknown}. "
4639 f"Available formats: {list(POST_FIELD_STATISTICS_FORMATS)}."
4640 )
4641 if len(set(formats)) != len(formats):
4642 raise ValueError(f"field statistics recipe for [{label}]: 'formats' lists a duplicate.")
4643
4644 homogeneous = raw.get("homogeneous", [])
4645 if (not isinstance(homogeneous, list) or any(axis not in ("i", "j", "k") for axis in homogeneous)
4646 or len(set(homogeneous)) != len(homogeneous)):
4647 raise ValueError(
4648 f"field statistics recipe for [{label}]: 'homogeneous' must list distinct axes from "
4649 f"['i', 'j', 'k'] (got {homogeneous!r})."
4650 )
4651 # An ensemble has no convergence history, so its CSV output is the profile alone.
4652 if ensembles and "csv" in formats and not homogeneous:
4653 raise ValueError(
4654 f"field statistics recipe for [{label}]: an ensemble writes CSV only as a profile, "
4655 "so 'csv' needs 'homogeneous' to name the directions to merge over."
4656 )
4657
4658 return {
4659 "windows": cleaned,
4660 "ensembles": ensembles,
4661 "homogeneous": [axis for axis in ("i", "j", "k") if axis in homogeneous],
4662 "source_step": source_step,
4663 "outputs": list(outputs),
4664 "formats": list(formats),
4665 }
4666
4667
4668def _post_window_derived_field_count(window_cfg: dict, outputs: list, eligible: dict = None) -> int:
4669 """!
4670 @brief Count the derived fields one window would produce for a set of outputs.
4671
4672 @details Mirrors the resolution the C derivation performs, so a recipe that would
4673 produce an empty file is refused before the run rather than after it. Each
4674 output resolves against what the window accumulated: a window keeping only
4675 first moments has no stresses, RMS, or turbulent kinetic energy to give.
4676
4677 @param[in] window_cfg Normalized window definition from monitor.yml.
4678 @param[in] outputs Requested output kinds.
4679 @param[in] eligible Field table the window's fields are named from; the Eulerian
4680 table when None.
4681 @return Value returned by `_post_window_derived_field_count()`.
4682 """
4683 eligible = STATISTICS_ELIGIBLE_FIELDS if eligible is None else eligible
4684 fields = window_cfg.get("fields", []) or []
4685 covariances = window_cfg.get("covariances", []) or []
4686 with_second = [f for f in fields if "second" in (f.get("moments") or [])]
4687 total = 0
4688 for kind in outputs:
4689 if kind == "mean":
4690 total += len(fields)
4691 elif kind == "reynolds_stress":
4692 for entry in with_second:
4693 dof = eligible[entry["field"]]["components"]
4694 total += 6 if dof == 3 else 1
4695 elif kind == "rms":
4696 for entry in with_second:
4697 total += eligible[entry["field"]]["components"]
4698 elif kind == "tke":
4699 total += sum(1 for entry in with_second
4700 if eligible[entry["field"]]["components"] == 3)
4701 elif kind == "flux":
4702 total += len(covariances)
4703 return total
4704
4705
4706def _post_requests_field_statistics(post_cfg: dict) -> bool:
4707 """!
4708 @brief Return whether the current post recipe derives accumulated field statistics.
4709 @param[in] post_cfg Argument passed to `_post_requests_field_statistics()`.
4710 @return Value returned by `_post_requests_field_statistics()`.
4711 """
4712 try:
4714 return bool(config["windows"] or config["ensembles"])
4715 except ValueError:
4716 # Malformed configuration is reported by validation, not by this predicate.
4717 return False
4718
4719
4720def get_post_field_statistics_artifacts(post_cfg: dict, run_dir: str):
4721 """!
4722 @brief Predict the per-window statistics artifacts a recipe will produce.
4723 @details Returns one entry per window and format, so resume tracking can tell a
4724 half-finished window from a completed one.
4725 @param[in] post_cfg Parsed post-processing configuration.
4726 @param[in] run_dir Run directory the outputs are written under.
4727 @return List of (kind, path_prefix, window) tuples; kind is 'vtk', 'csv' (a history
4728 shared by every step) or 'profile' (a prefix taking `_<step>.csv`). window is
4729 None for a recipe ensemble, which every processed step produces.
4730 """
4732 if not (config["windows"] or config["ensembles"]):
4733 return []
4734 io_cfg = post_cfg.get("io", {}) or {}
4735 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
4736 visualization_dir = _post_output_directory_abs(run_dir, post_cfg)
4737 visualization_prefix = io_cfg.get("output_filename_prefix", "Field")
4738 csv_prefix_path = None
4739 if internal.get("field_statistics_prefix"):
4740 csv_prefix_path = os.path.join(run_dir, internal["field_statistics_prefix"])
4741 csv_base = csv_prefix_path or os.path.join(visualization_dir, str(visualization_prefix))
4742 artifacts = []
4743 # An ensemble is built from the step itself, so every processed step produces it;
4744 # its entries carry no window, which marks them as always expected.
4745 owners = [(window, window) for window in config["windows"]]
4746 owners += [(ensemble["name"], None) for ensemble in config["ensembles"]]
4747 for name, window in owners:
4748 if "vtk" in config["formats"]:
4749 artifacts.append(("vtk", os.path.join(
4750 visualization_dir, f"{visualization_prefix}_statistics_{name}"
4751 ), window))
4752 if "csv" in config["formats"] and window is not None:
4753 artifacts.append(("csv", f"{csv_base}_statistics_{name}.csv", window))
4754 if "csv" in config["formats"] and config["homogeneous"]:
4755 artifacts.append(("profile", f"{csv_base}_profile_{name}", window))
4756 return artifacts
4757
4758
4759def build_post_recipe_config(post_cfg: dict, monitor_cfg=None) -> dict:
4760 """!
4761 @brief Build the flat key=value mapping consumed by the C post-processor.
4762 @param[in] post_cfg Argument passed to `build_post_recipe_config()`.
4763 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
4764 @return Value returned by `build_post_recipe_config()`.
4765 """
4766 c_config = {}
4767
4768 c_config['startTime'] = get_post_run_control_value(post_cfg, 'start_step', 0)
4769 c_config['endTime'] = get_post_run_control_value(post_cfg, 'end_step', 0)
4770 c_config['timeStep'] = get_post_run_control_value(post_cfg, 'step_interval', 1)
4771
4772 eulerian_pipeline_parts = []
4773 dimensionalize = bool(post_cfg.get('global_operations', {}).get('dimensionalize', False))
4774 if dimensionalize:
4775 # A setting rather than a pipeline stage: loaded fields are scaled as they are
4776 # read, derived statistics in the accumulator, particles after their MSD, and
4777 # spectra in their generator, each once, where the value is produced.
4778 c_config['dimensionalize'] = 'true'
4779
4780 for task in post_cfg.get('eulerian_pipeline', []):
4781 task_name = task.get('task')
4782 if task_name == 'q_criterion':
4783 eulerian_pipeline_parts.append('ComputeQCriterion')
4784 elif task_name == 'normalize_field':
4785 field = task.get('field', 'P')
4786 eulerian_pipeline_parts.append(f'NormalizeRelativeField:{field}')
4787 ref_point = task.get('reference_point', [1, 1, 1])
4788 c_config['reference_ip'] = ref_point[0]
4789 c_config['reference_jp'] = ref_point[1]
4790 c_config['reference_kp'] = ref_point[2]
4791 elif task_name == 'nodal_average':
4792 in_field = task.get('input_field')
4793 out_field = task.get('output_field')
4794 if in_field and out_field:
4795 eulerian_pipeline_parts.append(f'CellToNodeAverage:{in_field}>{out_field}')
4796
4797 if eulerian_pipeline_parts:
4798 c_config['process_pipeline'] = ";".join(eulerian_pipeline_parts)
4799
4800 lagrangian_pipeline_parts = []
4801 for task in post_cfg.get('lagrangian_pipeline', []):
4802 task_name = task.get('task')
4803 if task_name == 'specific_ke':
4804 in_field = task.get('input_field')
4805 out_field = task.get('output_field')
4806 if in_field and out_field:
4807 lagrangian_pipeline_parts.append(f'ComputeSpecificKE:{in_field}>{out_field}')
4808 if lagrangian_pipeline_parts:
4809 c_config['particle_pipeline'] = ";".join(lagrangian_pipeline_parts)
4810
4811 statistics_pipeline_parts = get_post_statistics_task_tokens(post_cfg)
4812 statistics_output_prefix = None
4813 stats_cfg = post_cfg.get('statistics_pipeline')
4814 if isinstance(stats_cfg, dict):
4815 statistics_output_prefix = stats_cfg.get('output_prefix')
4816
4817 if statistics_pipeline_parts:
4818 c_config['statistics_pipeline'] = ";".join(statistics_pipeline_parts)
4819 statistics_output_prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
4820 elif statistics_output_prefix is None:
4821 statistics_output_prefix = post_cfg.get('statistics_output_prefix')
4822 if statistics_output_prefix:
4823 c_config['statistics_output_prefix'] = statistics_output_prefix
4824
4825 io = post_cfg.get('io', {})
4826 internal_paths = post_cfg.get('_picurv_paths', {}) or {}
4827 field_statistics = normalize_post_field_statistics_config(post_cfg)
4828 if field_statistics["windows"] or field_statistics["ensembles"]:
4829 c_config['field_statistics_windows'] = ",".join(field_statistics["windows"])
4830 c_config['field_statistics_outputs'] = ",".join(field_statistics["outputs"])
4831 c_config['field_statistics_formats'] = ",".join(field_statistics["formats"])
4832 # An omitted source step means each processed step derives from its own
4833 # bundle, which is what turns a multi-step recipe into a convergence history.
4834 if field_statistics["source_step"] is not None:
4835 c_config['field_statistics_source_step'] = field_statistics["source_step"]
4836 if internal_paths.get("field_statistics_prefix"):
4837 c_config['field_statistics_output_prefix'] = internal_paths["field_statistics_prefix"]
4838 if field_statistics["homogeneous"]:
4839 c_config['field_statistics_homogeneous'] = ",".join(field_statistics["homogeneous"])
4840 # Ensemble lines are the run windows' option families under the ensemble kind;
4841 # the post-processor forwards them to the window parser it shares.
4842 if field_statistics["ensembles"]:
4843 c_config['field_statistics_ensemble_count'] = len(field_statistics["ensembles"])
4844 for index, ensemble in enumerate(field_statistics["ensembles"]):
4845 prefix = f"field_statistics_ensemble_{index}"
4846 c_config[f'{prefix}_name'] = ensemble["name"]
4847 c_config[f'{prefix}_source'] = ensemble["source"]
4848 c_config[f'{prefix}_field_count'] = len(ensemble["fields"])
4849 for field_index, field_entry in enumerate(ensemble["fields"]):
4850 c_config[f'{prefix}_field_{field_index}_name'] = field_entry["field"]
4851 c_config[f'{prefix}_field_{field_index}_moments'] = ",".join(field_entry["moments"])
4852 c_config[f'{prefix}_covariance_count'] = len(ensemble["covariances"])
4853 for pair_index, pair in enumerate(ensemble["covariances"]):
4854 c_config[f'{prefix}_covariance_{pair_index}'] = f"{pair[0]},{pair[1]}"
4855
4856 c_config['output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('output_filename_prefix', 'Field')
4857 c_config['particle_output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('particle_filename_prefix', 'Particle')
4858 c_config['output_particles'] = io.get('output_particles', False)
4859 c_config['particle_output_freq'] = io.get('particle_subsampling_frequency', 1)
4860 c_config['output_fields_instantaneous'] = ",".join(io.get('eulerian_fields', []))
4861 c_config['particle_fields_instantaneous'] = ",".join(io.get('particle_fields', []))
4862 input_extensions = get_post_input_extensions(post_cfg)
4863 if isinstance(input_extensions, dict):
4864 for extension_name in ('eulerian', 'particle'):
4865 extension = input_extensions.get(extension_name)
4866 if extension and str(extension).strip().lstrip('.').lower() != 'dat':
4867 raise ValueError(
4868 f"post input extension '{extension_name}' must be 'dat'; "
4869 "committed checkpoint payload names are fixed."
4870 )
4871
4872 source_directory = get_post_source_directory_template(post_cfg, default=None)
4873 if source_directory is not None:
4874 c_config['source_directory'] = source_directory
4875
4876 # Spectra run in the conductor's Python stage, but the recipe fingerprint is
4877 # computed over this mapping, so the block has to be represented here or a
4878 # changed spectra recipe would leave its steps' records looking valid. The C
4879 # post-processor accepts and ignores the key.
4880 spectra = normalize_post_spectra_config(post_cfg)
4881 if spectra["tasks"]:
4882 c_config['spectra_signature'] = compute_post_spectra_signature(spectra)
4883
4884 return c_config
4885
4886
4887def compute_post_recipe_id(post_cfg: dict) -> str:
4888 """!
4889 @brief Compute a stable human-readable identity for one post recipe.
4890 @param[in] post_cfg Parsed post configuration before runtime path injection.
4891 @return Filesystem-safe recipe identity.
4892 """
4893 normalized = copy.deepcopy(post_cfg or {})
4894 normalized.pop("_picurv_paths", None)
4895 # source_data.directory is always replaced by the run's own output, so an absent
4896 # block and an emptied one are the same recipe; both hash as the emptied form that
4897 # every earlier recipe, which carried the directory, reduced to.
4898 source = normalized.setdefault("source_data", {})
4899 if isinstance(source, dict):
4900 source.pop("directory", None)
4901 io = normalized.get("io")
4902 label = "post"
4903 if isinstance(io, dict):
4904 io.pop("output_directory", None)
4905 # ParaView collection generation changes only how existing VTK artifacts are
4906 # indexed. It must not split an otherwise identical computational recipe,
4907 # especially when a child enables lineage indexing for an ancestor whose VTK
4908 # files predate the option.
4909 io.pop("paraview_series", None)
4910 raw_label = io.get("output_filename_prefix")
4911 if isinstance(raw_label, str) and raw_label.strip():
4912 label = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw_label.strip()).strip("-") or "post"
4913 run_control = normalized.get("run_control")
4914 if isinstance(run_control, dict):
4915 for key in POST_RUN_CONTROL_ALIASES["start_step"] + POST_RUN_CONTROL_ALIASES["end_step"]:
4916 run_control.pop(key, None)
4917 # Reducers read the recipe's outputs without changing them, so adding or revising
4918 # one must not move the recipe to a new home and recompute every field.
4919 normalized.pop("reducers", None)
4920 digest = hashlib.sha256(
4921 json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8")
4922 ).hexdigest()[:12]
4923 return f"{label}-{digest}"
4924
4925
4926def apply_canonical_post_paths(post_cfg: dict, run_dir: str) -> "tuple[dict, str]":
4927 """!
4928 @brief Route every post artifact into its fixed analysis or visualization home.
4929 @param[in] post_cfg Parsed user recipe.
4930 @param[in] run_dir Run receiving derived artifacts.
4931 @return Runtime-only config copy and stable recipe id.
4932 """
4933 recipe_id = compute_post_recipe_id(post_cfg)
4934 runtime = copy.deepcopy(post_cfg)
4935 if not isinstance(runtime.get("source_data"), dict):
4936 runtime["source_data"] = {}
4937 runtime["source_data"]["directory"] = os.path.join(
4938 os.path.abspath(run_dir), CANONICAL_RUN_PATHS["output"]
4939 )
4940 io = runtime.setdefault("io", {})
4941 io["output_directory"] = os.path.join(CANONICAL_RUN_PATHS["visualization"], recipe_id)
4942 output_prefix = io.get("output_filename_prefix", "Field")
4943 stats_cfg = runtime.get("statistics_pipeline")
4944 if isinstance(stats_cfg, dict):
4945 basename = stats_cfg.get("output_prefix", "Stats")
4946 stats_cfg["output_prefix"] = os.path.join(
4947 CANONICAL_RUN_PATHS["statistics"], recipe_id, os.path.basename(str(basename))
4948 )
4949 runtime["_picurv_paths"] = {
4950 "recipe_id": recipe_id,
4951 "recipe_root": os.path.join("config", "post-recipes", recipe_id),
4952 "visualization": io["output_directory"],
4953 "statistics": os.path.join(CANONICAL_RUN_PATHS["statistics"], recipe_id),
4954 "field_statistics_prefix": os.path.join(
4955 CANONICAL_RUN_PATHS["statistics"], recipe_id, str(output_prefix)
4956 ),
4957 "spectra": os.path.join(CANONICAL_RUN_PATHS["spectra"], recipe_id),
4958 }
4959 return runtime, recipe_id
4960
4961
4962def normalize_post_recipe_signature(recipe_cfg: dict) -> dict:
4963 """!
4964 @brief Normalize post recipe settings into a stable signature mapping.
4965 @param[in] recipe_cfg Argument passed to `normalize_post_recipe_signature()`.
4966 @return Value returned by `normalize_post_recipe_signature()`.
4967 """
4968 signature = {}
4969 for key, value in (recipe_cfg or {}).items():
4970 if key in POST_RECIPE_SIGNATURE_EXCLUDED_KEYS or value is None:
4971 continue
4972 if isinstance(value, bool):
4973 text = 'true' if value else 'false'
4974 else:
4975 text = str(value).strip()
4976 if text.lower() in {'true', 'false'}:
4977 text = text.lower()
4978 if text:
4979 signature[str(key)] = text
4980 return signature
4981
4982
4983def compute_post_recipe_fingerprint(recipe_cfg: dict) -> "tuple[dict, str]":
4984 """!
4985 @brief Return normalized recipe signature plus SHA-256 fingerprint.
4986 @param[in] recipe_cfg Argument passed to `compute_post_recipe_fingerprint()`.
4987 @return Value returned by `compute_post_recipe_fingerprint()`.
4988 """
4989 signature = normalize_post_recipe_signature(recipe_cfg)
4990 payload = json.dumps(signature, sort_keys=True, separators=(',', ':')).encode('utf-8')
4991 return signature, hashlib.sha256(payload).hexdigest()
4992
4993
4994def get_post_resume_state_path(run_dir: str, post_cfg: dict = None) -> str:
4995 """!
4996 @brief Return the JSON resume metadata path for a run directory.
4997 @param[in] run_dir Argument passed to `get_post_resume_state_path()`.
4998 @param[in] post_cfg Optional versioned post recipe configuration.
4999 @return Value returned by `get_post_resume_state_path()`.
5000 """
5001 if post_cfg is None:
5002 return os.path.join(run_dir, 'config', POST_RESUME_STATE_FILENAME)
5003 return os.path.join(get_post_recipe_root(run_dir, post_cfg), "state.json")
5004
5005
5006def get_post_recipe_root(run_dir: str, post_cfg: dict) -> str:
5007 """!
5008 @brief Return the versioned run-local control directory for one post recipe.
5009 @param[in] run_dir Owning run root.
5010 @param[in] post_cfg Runtime post config with canonical paths, or a user recipe.
5011 @return Absolute recipe control directory.
5012 """
5013 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
5014 recipe_id = internal.get("recipe_id") or compute_post_recipe_id(post_cfg)
5015 return os.path.join(os.path.abspath(run_dir), "config", "post-recipes", recipe_id)
5016
5017
5018def get_post_lock_paths(run_dir: str, recipe_id: str = None) -> dict:
5019 """!
5020 @brief Return lock-wrapper related paths for a run directory.
5021 @param[in] run_dir Argument passed to `get_post_lock_paths()`.
5022 @param[in] recipe_id Optional stable recipe identity used to scope the lock.
5023 @return Value returned by `get_post_lock_paths()`.
5024 """
5025 scheduler_dir = os.path.join(run_dir, 'scheduler')
5026 suffix = f".{recipe_id}" if recipe_id else ""
5027 return {
5028 'lock_file': os.path.join(scheduler_dir, f"post{suffix}.lock"),
5029 'metadata_file': os.path.join(scheduler_dir, f"post{suffix}.lock.json"),
5030 'wrapper_path': os.path.join(scheduler_dir, POST_LOCK_WRAPPER_FILENAME),
5031 }
5032
5033
5034def _post_output_directory_abs(run_dir: str, post_cfg: dict) -> str:
5035 """!
5036 @brief Resolve the absolute post output directory for the current recipe.
5037 @param[in] run_dir Argument passed to `_post_output_directory_abs()`.
5038 @param[in] post_cfg Argument passed to `_post_output_directory_abs()`.
5039 @return Value returned by `_post_output_directory_abs()`.
5040 """
5041 io_cfg = post_cfg.get('io', {}) or {}
5042 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
5043 relative = internal.get("visualization") or io_cfg.get('output_directory')
5044 if not relative:
5045 relative = os.path.join(CANONICAL_RUN_PATHS["visualization"], compute_post_recipe_id(post_cfg))
5046 return os.path.abspath(os.path.join(run_dir, relative))
5047
5048
5049def _post_requests_eulerian_output(post_cfg: dict) -> bool:
5050 """!
5051 @brief Return whether the current post recipe expects Eulerian VTK output artifacts.
5052 @param[in] post_cfg Argument passed to `_post_requests_eulerian_output()`.
5053 @return Value returned by `_post_requests_eulerian_output()`.
5054 """
5055 io_cfg = post_cfg.get('io', {}) or {}
5056 return bool(io_cfg.get('eulerian_fields'))
5057
5058
5059def _post_requests_particle_output(post_cfg: dict) -> bool:
5060 """!
5061 @brief Return whether the current post recipe expects particle VTP output artifacts.
5062 @param[in] post_cfg Argument passed to `_post_requests_particle_output()`.
5063 @return Value returned by `_post_requests_particle_output()`.
5064 """
5065 io_cfg = post_cfg.get('io', {}) or {}
5066 return bool(io_cfg.get('output_particles')) and bool(io_cfg.get('particle_fields'))
5067
5068
5069POST_PARAVIEW_SERIES_SCOPES = {"run", "lineage"}
5070
5071
5072def normalize_post_paraview_series_config(post_cfg: dict) -> dict:
5073 """!
5074 @brief Normalize the optional ParaView collection policy in a post recipe.
5075 @param[in] post_cfg Parsed post-processing configuration.
5076 @return Mapping with enabled and scope keys.
5077 """
5078 raw = ((post_cfg or {}).get("io") or {}).get("paraview_series")
5079 if raw is None:
5080 return {"enabled": False, "scope": "run"}
5081 if isinstance(raw, bool):
5082 return {"enabled": raw, "scope": "lineage" if raw else "run"}
5083 if not isinstance(raw, dict):
5084 raise ValueError("'io.paraview_series' must be a boolean or mapping.")
5085 enabled = raw.get("enabled", True)
5086 scope = str(raw.get("scope", "lineage")).strip().lower()
5087 if not isinstance(enabled, bool):
5088 raise ValueError("'io.paraview_series.enabled' must be a boolean.")
5089 if scope not in POST_PARAVIEW_SERIES_SCOPES:
5090 raise ValueError("'io.paraview_series.scope' must be 'run' or 'lineage'.")
5091 return {"enabled": enabled, "scope": scope}
5092
5093
5094def _resolve_lineage_parent_run(child_run_dir: str, lineage: dict) -> str:
5095 """!
5096 @brief Resolve and identity-check one recorded lineage parent.
5097 @param[in] child_run_dir Child run directory used to resolve workspace-relative paths.
5098 @param[in] lineage Recorded branch-lineage mapping.
5099 @return Absolute path to the identity-matched parent run.
5100 """
5101 recorded = lineage.get("parent_path")
5102 parent_id = lineage.get("parent_run_id")
5103 if not recorded or not parent_id:
5104 raise ValueError(f"Run lineage is missing parent_path or parent_run_id: {child_run_dir}")
5105 if os.path.isabs(str(recorded)):
5106 candidates = [os.path.abspath(str(recorded))]
5107 else:
5108 workspace_root = find_workspace_root(child_run_dir)
5109 candidates = ([os.path.abspath(os.path.join(workspace_root, str(recorded)))]
5110 if workspace_root else [])
5111 workspace_root = find_workspace_root(child_run_dir)
5112 if workspace_root:
5113 runs_root = os.path.join(workspace_root, "runs")
5114 if os.path.isdir(runs_root):
5115 candidates.extend(
5116 os.path.join(runs_root, name) for name in os.listdir(runs_root)
5117 if os.path.isdir(os.path.join(runs_root, name))
5118 )
5119 seen = set()
5120 for candidate in candidates:
5121 candidate = os.path.abspath(candidate)
5122 if candidate in seen or not os.path.isdir(candidate):
5123 continue
5124 seen.add(candidate)
5125 try:
5126 identity = read_artifact_identity(candidate)
5127 except (OSError, ValueError, KeyError):
5128 continue
5129 if identity.get("run_id") == parent_id:
5130 return candidate
5131 raise ValueError(
5132 f"Lineage parent '{parent_id}' for run '{os.path.basename(child_run_dir)}' "
5133 f"could not be resolved from recorded path '{recorded}'."
5134 )
5135
5136
5137def resolve_run_lineage(run_dir: str, scope: str = "lineage") -> list:
5138 """!
5139 @brief Return root-to-leaf run segments, for a ParaView collection or a reducer's metrics.
5140 @param[in] run_dir Leaf run whose lineage is resolved.
5141 @param[in] scope Either run-local or full-lineage collection scope.
5142 @return Ordered run-segment mappings from root to leaf.
5143 @details Each entry carries the run path, manifest, and the checkpoint at which
5144 that run branched from its parent. Cycles and unresolved parents fail.
5145 """
5146 current = os.path.abspath(run_dir)
5147 reverse = []
5148 seen_ids = set()
5149 while True:
5150 manifest = _read_json_if_exists(os.path.join(current, "manifest.json")) or {}
5151 run_id = manifest.get("run_id") or os.path.basename(current)
5152 if run_id in seen_ids:
5153 chain = " -> ".join([entry["run_id"] for entry in reversed(reverse)] + [run_id])
5154 raise ValueError(f"Cycle detected while resolving visualization lineage: {chain}")
5155 seen_ids.add(run_id)
5156 lineage = manifest.get("lineage") or {"relationship": "root"}
5157 reverse.append({
5158 "run_dir": current,
5159 "run_id": run_id,
5160 "manifest": manifest,
5161 "lineage": lineage,
5162 "fork_step": (int(lineage["checkpoint_step"])
5163 if lineage.get("relationship") == "branch" else None),
5164 })
5165 if scope == "run" or lineage.get("relationship") != "branch":
5166 break
5167 current = _resolve_lineage_parent_run(current, lineage)
5168 return list(reversed(reverse))
5169
5170
5171def _existing_pvd_times(pvd_path: str) -> dict:
5172 """!
5173 @brief Read step-to-time fallbacks from an existing PVD collection.
5174 @param[in] pvd_path Existing collection path.
5175 @return Mapping from output step to physical time.
5176 """
5177 if not os.path.isfile(pvd_path):
5178 return {}
5179 try:
5180 root = ElementTree.parse(pvd_path).getroot()
5181 except (ElementTree.ParseError, OSError):
5182 return {}
5183 result = {}
5184 for dataset in root.findall(".//DataSet"):
5185 filename = os.path.basename(dataset.get("file", ""))
5186 match = re.search(r"_(\d+)\.(?:vts|vtp)$", filename)
5187 try:
5188 if match:
5189 result[int(match.group(1))] = float(dataset.get("timestep"))
5190 except (TypeError, ValueError):
5191 continue
5192 return result
5193
5194
5195def _checkpoint_time_for_series(run_dir: str, step: int, fallback: dict, time_scale: float = 1.0) -> float:
5196 """!
5197 @brief Read the authoritative time of one visualization frame.
5198 @details Checkpoints record solver time. A dimensionalized recipe writes physical
5199 frames, so its collection reports physical time: the checkpoint time
5200 multiplied by the owning run's reference time.
5201 @param[in] run_dir Run owning the frame.
5202 @param[in] step Frame checkpoint step.
5203 @param[in] fallback Existing collection times, already in the collection's units,
5204 used after checkpoint pruning.
5205 @param[in] time_scale Factor from solver time to the collection's time unit.
5206 @return Frame time in the collection's unit system.
5207 """
5208 metadata_path = os.path.join(
5209 run_dir, CANONICAL_RUN_PATHS["checkpoints"],
5210 f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}", "checkpoint.meta",
5211 )
5212 commit_path = os.path.join(os.path.dirname(metadata_path), "COMMITTED")
5213 if os.path.isfile(metadata_path) and os.path.isfile(commit_path):
5214 options = _read_checkpoint_options(metadata_path)
5215 try:
5216 recorded_step = int(options["checkpoint_step"])
5217 physical_time = float(options["checkpoint_time"])
5218 except (KeyError, TypeError, ValueError) as exc:
5219 raise ValueError(f"Checkpoint time metadata is malformed: {metadata_path}") from exc
5220 if recorded_step != step or not math.isfinite(physical_time):
5221 raise ValueError(f"Checkpoint time metadata does not match step {step}: {metadata_path}")
5222 return physical_time * time_scale
5223 if step in fallback:
5224 return float(fallback[step])
5225 raise ValueError(
5226 f"Visualization frame at step {step} has no locally available checkpoint time in "
5227 f"'{run_dir}'. Restore its checkpoints or retain an existing PVD carrying the time."
5228 )
5229
5230
5231def _write_pvd_collection(path: str, entries: list) -> None:
5232 """!
5233 @brief Atomically write one VTK Collection file.
5234 @param[in] path Destination PVD path.
5235 @param[in] entries Ordered frame mappings.
5236 @return None.
5237 """
5238 root = ElementTree.Element("VTKFile", {
5239 "type": "Collection", "version": "0.1", "byte_order": "LittleEndian",
5240 })
5241 collection = ElementTree.SubElement(root, "Collection")
5242 base = os.path.dirname(os.path.abspath(path))
5243 for entry in entries:
5244 ElementTree.SubElement(collection, "DataSet", {
5245 "timestep": format(float(entry["time"]), ".17g"),
5246 "group": "", "part": "0",
5247 "file": os.path.relpath(entry["path"], base).replace(os.sep, "/"),
5248 })
5249 content = '<?xml version="1.0"?>\n' + ElementTree.tostring(root, encoding="unicode") + "\n"
5250 os.makedirs(base, exist_ok=True)
5251 temporary = f"{path}.tmp.{os.getpid()}"
5252 try:
5253 with open(temporary, "w", encoding="utf-8") as stream:
5254 stream.write(content)
5255 stream.flush()
5256 os.fsync(stream.fileno())
5257 os.replace(temporary, path)
5258 finally:
5259 if os.path.exists(temporary):
5260 os.unlink(temporary)
5261
5262
5263def _lineage_reset_index(segments: list, family: str) -> int:
5264 """!
5265 @brief Return the first segment belonging to one continuous artifact family.
5266 @param[in] segments Root-to-leaf lineage segments.
5267 @param[in] family Artifact family being indexed.
5268 @return Index of the first segment after the last relevant reset.
5269 """
5270 start = 0
5271 for index, segment in enumerate(segments[1:], start=1):
5272 if family == "particle":
5273 case_path = os.path.join(segment["run_dir"], "config", "case.yml")
5274 case_cfg = read_yaml_file(case_path) if os.path.isfile(case_path) else {}
5275 particles = (((case_cfg.get("models") or {}).get("physics") or {}).get("particles") or {})
5276 if str(particles.get("restart_mode", "init")).strip().lower() != "load":
5277 start = index
5278 elif family == "statistics":
5279 if str(segment["lineage"].get("statistics_state", "reset")).lower() != "carry":
5280 start = index
5281 return start
5282
5283
5284def finalize_post_paraview_series(run_dir: str, post_cfg: dict) -> list:
5285 """!
5286 @brief Build physical-time ParaView collections from current or lineage VTK output.
5287 @param[in] run_dir Leaf run receiving the collection.
5288 @param[in] post_cfg Parsed post-processing recipe.
5289 @return Absolute PVD paths written. Disabled recipes return an empty list.
5290 """
5291 policy = normalize_post_paraview_series_config(post_cfg)
5292 if not policy["enabled"]:
5293 return []
5294 run_dir = os.path.abspath(run_dir)
5295 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
5296 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
5297 recipe_id = post_cfg["_picurv_paths"]["recipe_id"]
5298 requested_start, requested_end, _interval = resolve_post_requested_window(
5299 post_cfg,
5300 read_yaml_file(os.path.join(run_dir, "config", "case.yml")),
5301 )
5302 segments = resolve_run_lineage(run_dir, policy["scope"])
5303 io_cfg = post_cfg.get("io", {}) or {}
5304 families = []
5305 if _post_requests_eulerian_output(post_cfg):
5306 families.append(("field", io_cfg.get("output_filename_prefix", "Field"), "vts"))
5307 if _post_requests_particle_output(post_cfg):
5308 families.append(("particle", io_cfg.get("particle_filename_prefix", "Particle"), "vtp"))
5309 for kind, prefix_path, _window in get_post_field_statistics_artifacts(post_cfg, run_dir):
5310 if kind == "vtk":
5311 families.append(("statistics", os.path.basename(prefix_path), "vts"))
5312
5313 # Each run's frames are timed by that run's own reference time, so a lineage stays
5314 # physically continuous even across runs whose scales differ.
5315 dimensionalize = bool((post_cfg.get("global_operations") or {}).get("dimensionalize", False))
5316
5317 def segment_time_scale(segment: dict) -> float:
5318 """!
5319 @brief Return the factor from one lineage run's solver time to the collection's time.
5320 @param[in] segment Lineage segment.
5321 @return Reference time of the run when dimensionalizing, otherwise 1.
5322 """
5323 if not dimensionalize:
5324 return 1.0
5325 case_path = os.path.join(segment["run_dir"], "config", "case.yml")
5327
5328 written = []
5329 for family, prefix, extension in families:
5330 active_segments = segments[_lineage_reset_index(segments, family):]
5331 entries = []
5332 for index, segment in enumerate(active_segments):
5333 lower = requested_start
5334 if segment["fork_step"] is not None:
5335 lower = max(lower, int(segment["fork_step"]))
5336 upper = requested_end
5337 if index + 1 < len(active_segments):
5338 upper = min(upper, int(active_segments[index + 1]["fork_step"]) - 1)
5339 if lower > upper:
5340 continue
5341 viz_dir = os.path.join(
5342 segment["run_dir"], CANONICAL_RUN_PATHS["visualization"], recipe_id
5343 )
5344 pvd_path = os.path.join(viz_dir, f"{prefix}.pvd")
5345 fallback = _existing_pvd_times(pvd_path)
5346 pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)\.{re.escape(extension)}$")
5347 found = []
5348 if os.path.isdir(viz_dir):
5349 for name in os.listdir(viz_dir):
5350 match = pattern.match(name)
5351 if match and lower <= int(match.group(1)) <= upper:
5352 found.append((int(match.group(1)), os.path.join(viz_dir, name)))
5353 if not found and index + 1 < len(active_segments):
5354 raise ValueError(
5355 f"Lineage run '{segment['run_id']}' has no {prefix} visualization "
5356 f"frames for its required segment {lower}..{upper} and recipe '{recipe_id}'."
5357 )
5358 for step, frame_path in sorted(found):
5359 entries.append({
5360 "step": step,
5361 "time": _checkpoint_time_for_series(segment["run_dir"], step, fallback,
5362 segment_time_scale(segment)),
5363 "path": os.path.abspath(frame_path),
5364 "run_id": segment["run_id"],
5365 })
5366 if not entries:
5367 continue
5368 for previous, current in zip(entries, entries[1:]):
5369 if current["time"] <= previous["time"]:
5370 raise ValueError(
5371 f"ParaView series time is not strictly increasing: {previous['run_id']} "
5372 f"step {previous['step']} has {previous['time']:.17g}, followed by "
5373 f"{current['run_id']} step {current['step']} at {current['time']:.17g}."
5374 )
5375 output_dir = _post_output_directory_abs(run_dir, post_cfg)
5376 output_path = os.path.join(output_dir, f"{prefix}.pvd")
5377 _write_pvd_collection(output_path, entries)
5378 print(
5379 f"[SUCCESS] Wrote ParaView {family} series: {output_path} "
5380 f"({len(entries)} frame(s), time {entries[0]['time']:.17g}..{entries[-1]['time']:.17g})"
5381 )
5382 written.append(output_path)
5383 return written
5384
5385
5387 """!
5388 @brief Return the importable serial finalizer used by generated jobs.
5389 @return Python source suitable for a serial interpreter invocation.
5390 """
5391 package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5392 return (
5393 "import os,sys;"
5394 f"sys.path.insert(0,{package_root!r});"
5395 "from picurv_cli.core import read_yaml_file,apply_canonical_post_paths,finalize_post_outputs;"
5396 "run=os.path.abspath(sys.argv[1]);cfg=read_yaml_file(sys.argv[2]);"
5397 "cfg,_=apply_canonical_post_paths(cfg,run);finalize_post_outputs(run,cfg,(sys.argv[3] if len(sys.argv)>3 else '').split(','))"
5398 )
5399
5400
5401def build_post_finalize_command(run_dir: str, archived_post_path: str, post_cfg: dict,
5402 post_stages=POST_STAGE_NAMES) -> list:
5403 """!
5404 @brief Build the serial post-success command for staged execution.
5405 @details It runs after the field post-processor succeeds, in the conductor or in the
5406 submitted job, so whatever reads the field stage's output belongs here: the
5407 ParaView collections and the reducers.
5408 @param[in] run_dir Run receiving the collection.
5409 @param[in] archived_post_path Archived post recipe read by the finalizer.
5410 @param[in] post_cfg Parsed post-processing recipe.
5411 @param[in] post_stages Selected post stages; reducers run only when selected.
5412 @return Command tokens, or an empty list when there is nothing to finalize.
5413 """
5414 reducers = "reducers" in post_stages and bool(normalize_post_reducers_config(post_cfg)["tasks"])
5415 if not normalize_post_paraview_series_config(post_cfg)["enabled"] and not reducers:
5416 return []
5417 return [
5418 sys.executable, "-c", _post_finalize_python_source(),
5419 os.path.abspath(run_dir), os.path.abspath(archived_post_path),
5420 "reducers" if reducers else "-",
5421 ]
5422
5423
5424def finalize_post_outputs(run_dir: str, post_cfg: dict, stages) -> None:
5425 """!
5426 @brief Run every serial step that reads a successful field stage's output.
5427 @param[in] run_dir Run directory.
5428 @param[in] post_cfg Recipe with canonical paths applied.
5429 @param[in] stages Stage names the finalizer covers beyond the collections.
5430 @return None.
5431 """
5432 finalize_post_paraview_series(run_dir, post_cfg)
5433 if "reducers" in stages:
5434 run_post_reducers_stage(run_dir, post_cfg)
5435
5436
5437def _post_requests_statistics(post_cfg: dict) -> bool:
5438 """!
5439 @brief Return whether the current post recipe expects statistics CSV artifacts.
5440 @param[in] post_cfg Argument passed to `_post_requests_statistics()`.
5441 @return Value returned by `_post_requests_statistics()`.
5442 """
5443 return bool(get_post_statistics_task_tokens(post_cfg))
5444
5445
5446def _post_needs_particle_source(post_cfg: dict) -> bool:
5447 """!
5448 @brief Return whether the current post recipe requires particle source files to be present.
5449 @param[in] post_cfg Argument passed to `_post_needs_particle_source()`.
5450 @return Value returned by `_post_needs_particle_source()`.
5451 """
5452 io_cfg = post_cfg.get('io', {}) or {}
5453 return bool(io_cfg.get('output_particles')) or bool(post_cfg.get('lagrangian_pipeline')) or _post_requests_statistics(post_cfg)
5454
5455
5456def _iter_post_steps(start_step: int, end_step: int, step_interval: int):
5457 """!
5458 @brief Yield configured post-processing steps inclusively.
5459 @param[in] start_step Argument passed to `_iter_post_steps()`.
5460 @param[in] end_step Argument passed to `_iter_post_steps()`.
5461 @param[in] step_interval Argument passed to `_iter_post_steps()`.
5462 """
5463 if step_interval <= 0 or end_step < start_step:
5464 return
5465 step = start_step
5466 while step <= end_step:
5467 yield step
5468 step += step_interval
5469
5470
5471def resolve_post_requested_window(post_cfg: dict, case_cfg: dict = None) -> "tuple[int, int, int]":
5472 """!
5473 @brief Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
5474 @param[in] post_cfg Argument passed to `resolve_post_requested_window()`.
5475 @param[in] case_cfg Optional case configuration for end-step expansion.
5476 @return Value returned by `resolve_post_requested_window()`.
5477 """
5478 start_step = int(get_post_run_control_value(post_cfg, 'start_step', 0) or 0)
5479 end_step = int(get_post_run_control_value(post_cfg, 'end_step', 0) or 0)
5480 step_interval = int(get_post_run_control_value(post_cfg, 'step_interval', 1))
5481 if step_interval <= 0:
5482 raise ValueError('run_control.step_interval must be positive.')
5483 if end_step < 0 and case_cfg:
5484 case_run = case_cfg.get('run_control', {}) or {}
5485 case_start = int(case_run.get('start_step', 0) or 0)
5486 case_total = int(case_run.get('total_steps', 0) or 0)
5487 end_step = case_start + case_total
5488 return start_step, end_step, step_interval
5489
5490
5491def resolve_post_owned_start(run_dir: str, post_cfg: dict, requested_start: int,
5492 step_interval: int) -> int:
5493 """!
5494 @brief Intersect a recipe's logical cadence with this run's branch.
5495 @details A branch run holds checkpoints only from the step it forked at, so steps
5496 before it belong to its ancestors and never appear here; the run's window
5497 starts at its first cadence step at or after the fork.
5498 @param[in] run_dir Run whose branch ownership is inspected.
5499 @param[in] post_cfg Parsed post-processing recipe.
5500 @param[in] requested_start Logical recipe start step.
5501 @param[in] step_interval Logical recipe cadence.
5502 @return First cadence step owned by the run.
5503 """
5504 del post_cfg
5505 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
5506 lineage = manifest.get("lineage") or {}
5507 if lineage.get("relationship") != "branch":
5508 return requested_start
5509 fork_step = int(lineage["checkpoint_step"])
5510 if fork_step <= requested_start:
5511 return requested_start
5512 intervals = (fork_step - requested_start + step_interval - 1) // step_interval
5513 return requested_start + intervals * step_interval
5514
5515
5516def prepare_effective_post_config(post_cfg: dict, resolved_source_dir: str, start_step: int = None, end_step: int = None) -> dict:
5517 """!
5518 @brief Return a copy of post_cfg with resolved source dir and optional effective bounds.
5519 @param[in] post_cfg Argument passed to `prepare_effective_post_config()`.
5520 @param[in] resolved_source_dir Argument passed to `prepare_effective_post_config()`.
5521 @param[in] start_step Argument passed to `prepare_effective_post_config()`.
5522 @param[in] end_step Argument passed to `prepare_effective_post_config()`.
5523 @return Value returned by `prepare_effective_post_config()`.
5524 """
5525 effective_cfg = copy.deepcopy(post_cfg)
5526 if not isinstance(effective_cfg.get('source_data'), dict):
5527 effective_cfg['source_data'] = {}
5528 effective_cfg['source_data']['directory'] = resolved_source_dir
5529 rc = effective_cfg.setdefault('run_control', {})
5530 if start_step is not None:
5531 rc['start_step'] = int(start_step)
5532 if end_step is not None:
5533 rc['end_step'] = int(end_step)
5534 return effective_cfg
5535
5536
5537def _scan_post_vtk_steps(prefix_path: str, extension: str) -> "dict[int, str]":
5538 """!
5539 @brief Collect the VTK files named with a prefix, step suffix, and extension.
5540 @param[in] prefix_path Output path prefix before the numeric step suffix.
5541 @param[in] extension VTK file extension to match without its leading dot.
5542 @return Mapping of each step number to its file path in the prefix directory.
5543 """
5544 directory = os.path.dirname(prefix_path)
5545 if not os.path.isdir(directory):
5546 return {}
5547 basename = os.path.basename(prefix_path)
5548 pattern = re.compile(rf'^{re.escape(basename)}_(\d+)\.{re.escape(extension)}$')
5549 steps = {}
5550 for name in os.listdir(directory):
5551 match = pattern.match(name)
5552 if match:
5553 steps[int(match.group(1))] = os.path.join(directory, name)
5554 return steps
5555
5556
5557def _scan_post_statistics_csv_steps(csv_path: str) -> "set[int]":
5558 """!
5559 @brief Scan step ids from the first CSV column of a statistics artifact.
5560 @param[in] csv_path Argument passed to `_scan_post_statistics_csv_steps()`.
5561 @return Value returned by `_scan_post_statistics_csv_steps()`.
5562 """
5563 if not os.path.isfile(csv_path):
5564 return set()
5565 steps = set()
5566 with open(csv_path, 'r', encoding='utf-8', errors='replace', newline='') as f:
5567 reader = csv.reader(f)
5568 for row in reader:
5569 if not row:
5570 continue
5571 head = str(row[0]).strip().lower()
5572 if head in {'step', 'timestep', 'time_step'}:
5573 continue
5574 step_val = _parse_int_loose(row[0])
5575 if step_val is not None:
5576 steps.add(step_val)
5577 return steps
5578
5579
5580def collect_post_completion_families(run_dir: str, post_cfg: dict, monitor_cfg=None) -> "list[tuple]":
5581 """!
5582 @brief Collect the output files each output family of the current recipe holds per step.
5583 @param[in] run_dir Argument passed to `collect_post_completion_families()`.
5584 @param[in] post_cfg Argument passed to `collect_post_completion_families()`.
5585 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
5586 @return (step-to-path mapping, window, per_step) per family; window names the
5587 field-statistics window a family belongs to, or is None for a family every
5588 processed step must produce; per_step is False for a CSV shared by all steps.
5589 """
5590 io_cfg = post_cfg.get('io', {}) or {}
5591 output_dir_abs = _post_output_directory_abs(run_dir, post_cfg)
5592 families = []
5593
5594 def csv_family(path):
5595 """!
5596 @brief Map every step a shared CSV holds to that CSV.
5597 @param[in] path CSV artifact path.
5598 @return Mapping of step to the CSV path.
5599 """
5600 return {step: path for step in _scan_post_statistics_csv_steps(path)}
5601
5602 if _post_requests_eulerian_output(post_cfg):
5603 prefix = os.path.join(output_dir_abs, io_cfg.get('output_filename_prefix', 'Field'))
5604 families.append((_scan_post_vtk_steps(prefix, 'vts'), None, True))
5605
5606 if _post_requests_particle_output(post_cfg):
5607 prefix = os.path.join(output_dir_abs, io_cfg.get('particle_filename_prefix', 'Particle'))
5608 families.append((_scan_post_vtk_steps(prefix, 'vtp'), None, True))
5609
5610 for stats_path in get_post_statistics_output_artifacts(post_cfg, run_dir, monitor_cfg):
5611 families.append((csv_family(stats_path), None, False))
5612
5613 # Each window and format is its own family, so a run that produced the Eulerian
5614 # fields but not the statistics is not mistaken for a completed step.
5615 for kind, path, window in get_post_field_statistics_artifacts(post_cfg, run_dir):
5616 if kind == 'vtk':
5617 families.append((_scan_post_vtk_steps(path, 'vts'), window, True))
5618 elif kind == 'profile':
5619 families.append((_scan_post_vtk_steps(path, 'csv'), window, True))
5620 else:
5621 families.append((csv_family(path), window, False))
5622
5623 return families
5624
5625
5626def _field_statistics_window_samples(run_dir: str, step: int):
5627 """!
5628 @brief Read each field-statistics window's sample count from one committed checkpoint.
5629 @param[in] run_dir Run directory whose output holds the checkpoint.
5630 @param[in] step Checkpoint step.
5631 @return Mapping of window name to sample count, or None when the metadata is absent
5632 (never written, pruned, or offloaded).
5633 """
5634 metadata = os.path.join(
5635 _checkpoint_bundle_path(os.path.join(run_dir, CANONICAL_RUN_PATHS["output"]), step),
5636 "checkpoint.meta",
5637 )
5638 if not os.path.isfile(metadata):
5639 return None
5640 options = _read_checkpoint_options(metadata)
5641 samples = {}
5642 for index in range(int(options.get("checkpoint_statistics_window_count", 0) or 0)):
5643 name = options.get(f"checkpoint_statistics_window_{index}_name")
5644 if name is not None:
5645 samples[name] = int(options.get(f"checkpoint_statistics_window_{index}_sample_count", 0) or 0)
5646 return samples
5647
5648
5649def post_step_outputs(run_dir: str, post_cfg: dict, monitor_cfg=None):
5650 """!
5651 @brief Return a lookup of the output files one step of the current recipe holds.
5652 @details A step is complete when every family it must produce has its output. The
5653 post-processor writes a statistics window's output at a step only if the
5654 window had accumulated a sample in the bundle it reads, the pinned
5655 source_step or the step itself, so a window that had not opened yet is not
5656 required there.
5657 @param[in] run_dir Run whose output is inspected.
5658 @param[in] post_cfg Runtime recipe with canonical paths.
5659 @param[in] monitor_cfg Parsed monitor configuration.
5660 @return Function mapping a step to (per-step files, shared files), or None when the
5661 step lacks an output it must have.
5662 """
5663 families = collect_post_completion_families(run_dir, post_cfg, monitor_cfg)
5664 pinned = normalize_post_field_statistics_config(post_cfg).get("source_step")
5665 sample_cache = {}
5666
5667 def expected(window, step):
5668 """!
5669 @brief Whether a family's output must exist at this step.
5670 @param[in] window Field-statistics window the family belongs to, or None.
5671 @param[in] step Processed step.
5672 @return True when the family is always required or its window had samples.
5673 """
5674 if window is None:
5675 return True
5676 source = int(pinned) if pinned is not None else step
5677 if source not in sample_cache:
5678 sample_cache[source] = _field_statistics_window_samples(run_dir, source)
5679 samples = sample_cache[source]
5680 # Without the checkpoint's record the output cannot be ruled out, so it stays required.
5681 return samples is None or samples.get(window, 0) > 0
5682
5683 def outputs(step):
5684 """!
5685 @brief Collect one step's output files.
5686 @param[in] step Processed step.
5687 @return (per-step files, shared files), or None when an expected output is missing.
5688 """
5689 own, shared = [], []
5690 for mapping, window, per_step in families:
5691 if step in mapping:
5692 (own if per_step else shared).append(mapping[step])
5693 elif expected(window, step):
5694 return None
5695 return own, shared
5696
5697 return outputs
5698
5699
5700def _nearest_step(steps: "set[int]", target: int):
5701 """!
5702 @brief Return the complete source step nearest to a target step.
5703 @param[in] steps Candidate step numbers.
5704 @param[in] target Target step number.
5705 @return Nearest candidate, or None when no candidates exist.
5706 """
5707 if not steps:
5708 return None
5709 return min(steps, key=lambda step: (abs(step - target), step))
5710
5711
5712def _format_optional_step(step) -> str:
5713 """!
5714 @brief Format an optional step number for user-facing diagnostics.
5715 @param[in] step Step number or None.
5716 @return Printable step text.
5717 """
5718 return 'none' if step is None else str(step)
5719
5720
5721def _scan_complete_source_steps(source_dir: str, monitor_cfg: dict, post_cfg: dict) -> "tuple[set[int], dict]":
5722 """!
5723 @brief Scan source artifacts and return steps with every file required by the recipe.
5724 @param[in] source_dir Source output root directory.
5725 @param[in] monitor_cfg Parsed monitor configuration.
5726 @param[in] post_cfg Parsed post-processing configuration.
5727 @return Tuple of complete source steps and source path metadata.
5728 """
5729 del monitor_cfg
5730 require_particles = _post_needs_particle_source(post_cfg)
5731 complete_steps = _scan_committed_checkpoint_steps(
5732 source_dir, require_particles=require_particles
5733 )
5734 return complete_steps, {
5735 'source_dir': os.path.abspath(source_dir),
5736 'require_particles': require_particles,
5737 }
5738
5739
5740def _expected_source_paths_for_step(step: int, source_scan: dict, post_cfg: dict) -> "list[str]":
5741 """!
5742 @brief Build required source file paths for a single post-processing step.
5743 @param[in] step Requested step number.
5744 @param[in] source_scan Metadata returned by `_scan_complete_source_steps()`.
5745 @param[in] post_cfg Parsed post-processing configuration.
5746 @return Required source artifact paths.
5747 """
5748 del post_cfg
5749 bundle = _checkpoint_bundle_path(source_scan['source_dir'], step)
5750 paths = [os.path.join(bundle, 'checkpoint.meta'), os.path.join(bundle, 'COMMITTED')]
5751 if source_scan.get('require_particles'):
5752 paths.append(os.path.join(bundle, 'particles', 'position.dat'))
5753 return paths
5754
5755
5756def detect_post_source_frontier(source_dir: str, monitor_cfg: dict, post_cfg: dict, start_step: int, end_step: int, step_interval: int) -> dict:
5757 """!
5758 @brief Detect the highest contiguous fully available source step for live post-processing.
5759 @param[in] source_dir Argument passed to `detect_post_source_frontier()`.
5760 @param[in] monitor_cfg Argument passed to `detect_post_source_frontier()`.
5761 @param[in] post_cfg Argument passed to `detect_post_source_frontier()`.
5762 @param[in] start_step Argument passed to `detect_post_source_frontier()`.
5763 @param[in] end_step Argument passed to `detect_post_source_frontier()`.
5764 @param[in] step_interval Argument passed to `detect_post_source_frontier()`.
5765 @return Value returned by `detect_post_source_frontier()`.
5766 """
5767 diagnostic = {
5768 'first_requested_step': start_step,
5769 'first_incomplete_step': None,
5770 'missing_files_for_first_incomplete_step': [],
5771 'closest_complete_step_to_start': None,
5772 'closest_complete_step_to_end': None,
5773 }
5774 if step_interval <= 0 or end_step < start_step or not os.path.isdir(source_dir):
5775 return {
5776 'frontier_step': None,
5777 'diagnostic': diagnostic,
5778 }
5779
5780 complete_steps, source_scan = _scan_complete_source_steps(source_dir, monitor_cfg, post_cfg)
5781 diagnostic['closest_complete_step_to_start'] = _nearest_step(complete_steps, start_step)
5782 diagnostic['closest_complete_step_to_end'] = _nearest_step(complete_steps, end_step)
5783
5784 frontier = None
5785 for step in _iter_post_steps(start_step, end_step, step_interval):
5786 try:
5788 source_dir, step,
5789 require_particles=source_scan.get('require_particles', False),
5790 )
5791 except CheckpointIndexWidthError:
5792 raise
5793 except ValueError:
5794 expected_paths = _expected_source_paths_for_step(step, source_scan, post_cfg)
5795 diagnostic['first_incomplete_step'] = step
5796 diagnostic['missing_files_for_first_incomplete_step'] = [
5797 os.path.relpath(path, source_dir) for path in expected_paths if not os.path.isfile(path)
5798 ]
5799 if not diagnostic['missing_files_for_first_incomplete_step']:
5800 diagnostic['missing_files_for_first_incomplete_step'] = [
5801 os.path.relpath(_checkpoint_bundle_path(source_dir, step), source_dir)
5802 + ' (invalid bundle)'
5803 ]
5804 break
5805 frontier = step
5806 return {
5807 'frontier_step': frontier,
5808 'diagnostic': diagnostic,
5809 }
5810
5811
5812def _read_post_records(state_path: str) -> "tuple[dict, dict]":
5813 """!
5814 @brief Read the per-step output records a recipe's state holds.
5815 @param[in] state_path The recipe's state.json.
5816 @return (field-stage records, spectra records), each keyed by the step as text; both
5817 empty for a state written before records existed.
5818 """
5819 state = _read_json_if_exists(state_path)
5820 if not isinstance(state, dict) or state.get("schema_version") != POST_RESUME_SCHEMA_VERSION:
5821 return {}, {}
5822 return dict(state.get("steps") or {}), dict(state.get("spectra") or {})
5823
5824
5825def persist_post_resume_state(run_dir: str, plan: dict, steps=None, spectra=None):
5826 """!
5827 @brief Write a recipe's state: the window last planned and its per-step output records.
5828 @param[in] run_dir Run the recipe belongs to.
5829 @param[in] plan Plan from build_post_execution_plan().
5830 @param[in] steps Field-stage records to merge in, keyed by step, or None.
5831 @param[in] spectra Spectra records to merge in, keyed by step, or None.
5832 @return Path of the written state file.
5833 """
5834 state_path = plan.get("resume_state_path") or get_post_resume_state_path(run_dir)
5835 field_records, spectra_records = _read_post_records(state_path)
5836 field_records.update({str(step): record for step, record in (steps or {}).items()})
5837 spectra_records.update({str(step): record for step, record in (spectra or {}).items()})
5838 write_json_file(state_path, {
5839 'schema_version': POST_RESUME_SCHEMA_VERSION,
5840 'run_id': plan.get('run_id'),
5841 'recipe_fingerprint': plan.get('recipe_fingerprint'),
5842 'recipe_signature': plan.get('recipe_signature'),
5843 'requested_start_step': plan.get('requested_start_step'),
5844 'requested_end_step': plan.get('requested_end_step'),
5845 'step_interval': plan.get('step_interval'),
5846 'source_directory': plan.get('source_data_directory'),
5847 'updated_at': datetime.now().isoformat(),
5848 'steps': dict(sorted(field_records.items(), key=lambda item: int(item[0]))),
5849 'spectra': dict(sorted(spectra_records.items(), key=lambda item: int(item[0]))),
5850 })
5851 return state_path
5852
5853
5854def _checkpoint_commit(source_dir: str, step: int):
5855 """!
5856 @brief Identify one committed checkpoint by its commit marker.
5857 @param[in] source_dir Output root holding the checkpoints.
5858 @param[in] step Checkpoint step.
5859 @return (marker digest, marker modification time), or None when the checkpoint is not
5860 local (never written, archived, or pruned).
5861 """
5862 marker = os.path.join(_checkpoint_bundle_path(source_dir, step), "COMMITTED")
5863 try:
5864 with open(marker, "r", encoding="ascii") as stream:
5865 return stream.read().strip(), os.stat(marker).st_mtime
5866 except OSError:
5867 return None
5868
5869
5870def classify_post_step(record, files, commit, recipe: str, recompute: bool = False) -> "str | None":
5871 """!
5872 @brief Decide whether one step's existing output is still valid.
5873 @details Output stays valid while it was made by the same recipe from the checkpoint
5874 now on disk. The build that made it does not invalidate it: a rebuild changes
5875 every binary's hash, and redoing a whole window after each one is what
5876 `--recompute` is for. Output without a record (written before records
5877 existed, or by a job killed before it could record) is accepted when it is
5878 newer than its checkpoint, or when the checkpoint is no longer local.
5879 @param[in] record The step's record, or None.
5880 @param[in] files Files the step's output consists of, or None when any is missing.
5881 @param[in] commit Checkpoint identity from _checkpoint_commit(), or None.
5882 @param[in] recipe Current recipe fingerprint.
5883 @param[in] recompute Whether the invocation regenerates every step.
5884 @return None when the output is valid, else the reason it must be (re)made:
5885 `missing`, `recompute`, `recipe`, or `checkpoint`.
5886 """
5887 if files is None or (not files and record is None):
5888 return "missing"
5889 if recompute:
5890 return "recompute"
5891 if record is None:
5892 if commit is None or min(os.path.getmtime(path) for path in files) >= commit[1]:
5893 return None
5894 return "checkpoint"
5895 if record.get("recipe") != recipe:
5896 return "recipe"
5897 if commit is not None and record.get("checkpoint") not in (None, commit[0]):
5898 return "checkpoint"
5899 return None
5900
5901
5902def warn_overlapping_post_recipes(run_dir: str, post_cfg: dict, plan: dict, post_stages) -> None:
5903 """!
5904 @brief Warn when another recipe in this run already produced outputs this one repeats.
5905 @details A recipe writes one file per step holding all its fields, so a recipe that
5906 repeats another's fields recomputes them rather than reusing them. The
5907 warning names the shared fields and statistics windows and the overlapping
5908 steps, so the new outputs can go into a recipe of their own instead.
5909 @param[in] run_dir Run being post-processed.
5910 @param[in] post_cfg Runtime recipe with canonical paths.
5911 @param[in] plan Plan from build_post_execution_plan().
5912 @param[in] post_stages Selected post stages.
5913 @return None.
5914 """
5915 if 'fields' not in post_stages:
5916 return
5917 current_id = ((post_cfg.get("_picurv_paths") or {}).get("recipe_id")
5918 or compute_post_recipe_id(post_cfg))
5919 steps = set(plan['steps_to_process'])
5920 fields = set(((post_cfg.get('io') or {}).get('eulerian_fields')) or [])
5921 windows = set(normalize_post_field_statistics_config(post_cfg)["windows"])
5922 root = os.path.join(os.path.abspath(run_dir), "config", "post-recipes")
5923 if not steps or not (fields or windows) or not os.path.isdir(root):
5924 return
5925 for other_id in sorted(os.listdir(root)):
5926 archived = os.path.join(root, other_id, "post.yml")
5927 if other_id == current_id or not os.path.isfile(archived):
5928 continue
5929 try:
5930 other_cfg, resolved_id = apply_canonical_post_paths(read_yaml_file(archived), run_dir)
5931 except Exception: # an unreadable archived recipe says nothing about overlap
5932 continue
5933 if resolved_id != other_id:
5934 continue
5935 labels, done = [], set()
5936 shared_fields = fields & set(((other_cfg.get('io') or {}).get('eulerian_fields')) or [])
5937 if shared_fields:
5938 prefix = os.path.join(_post_output_directory_abs(run_dir, other_cfg),
5939 (other_cfg.get('io') or {}).get('output_filename_prefix', 'Field'))
5940 found = steps & _scan_post_vtk_steps(prefix, 'vts').keys()
5941 if found:
5942 labels.append(f"fields {', '.join(sorted(shared_fields))}")
5943 done |= found
5944 shared_windows = []
5945 for kind, path, window in get_post_field_statistics_artifacts(other_cfg, run_dir):
5946 if kind == 'vtk' and window in windows:
5947 found = steps & _scan_post_vtk_steps(path, 'vts').keys()
5948 if found:
5949 shared_windows.append(window)
5950 done |= found
5951 if shared_windows:
5952 labels.append(f"statistics windows {', '.join(shared_windows)}")
5953 if labels:
5954 print(f"[WARNING] Recipe {other_id} already produced {'; '.join(labels)} for "
5955 f"{len(done)} of these steps ({min(done)}..{max(done)}); this recipe computes "
5956 "them again. If only some outputs are new, a recipe holding just those avoids "
5957 "the repeat.", file=sys.stderr)
5958
5959
5961 """!
5962 @brief Return the Python wrapper that runs one post job under an exclusive lock.
5963 @details Given `--post-recipe`, the wrapper plans the job's steps as it starts
5964 (prepare_post_job()), skips the postprocessor when none need work, and
5965 records the steps produced when the postprocessor exits, even on failure
5966 (finish_post_job()). It imports the conductor from this installation, so it
5967 must run under the conductor's interpreter.
5968 @return Value returned by `_build_post_lock_wrapper_source()`.
5969 """
5970 package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5971 return """#!/usr/bin/env python3
5972import argparse
5973import fcntl
5974import json
5975import os
5976import socket
5977import subprocess
5978import sys
5979import time
5980
5981PICURV_PACKAGE_ROOT = """ + repr(package_root) + """
5982
5983
5984def main():
5985 parser = argparse.ArgumentParser(description='PICurv post-stage lock wrapper')
5986 parser.add_argument('--lock-file', required=True)
5987 parser.add_argument('--metadata-file', required=True)
5988 parser.add_argument('--run-dir', required=True)
5989 parser.add_argument('--recipe-fingerprint', default='')
5990 parser.add_argument('--post-recipe')
5991 parser.add_argument('--postprocessor')
5992 parser.add_argument('--recompute', action='store_true')
5993 parser.add_argument('--success-command-json')
5994 parser.add_argument('command', nargs=argparse.REMAINDER)
5995 args = parser.parse_args()
5996
5997 command = list(args.command or [])
5998 if not command or command[0] != '--':
5999 parser.error("expected '-- <command ...>' after wrapper arguments")
6000 command = command[1:]
6001
6002 os.makedirs(os.path.dirname(args.lock_file), exist_ok=True)
6003 fd = os.open(args.lock_file, os.O_RDWR | os.O_CREAT, 0o644)
6004 try:
6005 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
6006 except BlockingIOError:
6007 owner = None
6008 try:
6009 with open(args.metadata_file, 'r', encoding='utf-8') as handle:
6010 owner = json.load(handle)
6011 except Exception:
6012 owner = None
6013 if owner:
6014 print(
6015 f"[FATAL] Post stage already active for {args.run_dir} "
6016 f"(pid={owner.get('pid')}, host={owner.get('host')}, started_at={owner.get('started_at')}).",
6017 file=sys.stderr,
6018 )
6019 else:
6020 print(f"[FATAL] Post stage already active for {args.run_dir}.", file=sys.stderr)
6021 return 2
6022
6023 metadata = {
6024 'pid': os.getpid(),
6025 'host': socket.gethostname(),
6026 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
6027 'run_dir': args.run_dir,
6028 'recipe_fingerprint': args.recipe_fingerprint,
6029 'command': command,
6030 }
6031 with open(args.metadata_file, 'w', encoding='utf-8') as handle:
6032 json.dump(metadata, handle, indent=2, sort_keys=True)
6033 handle.write('\\n')
6034
6035 try:
6036 planned = None
6037 if args.post_recipe:
6038 sys.path.insert(0, PICURV_PACKAGE_ROOT)
6039 from picurv_cli.core import finish_post_job, prepare_post_job
6040 planned = prepare_post_job(args.run_dir, args.post_recipe, args.postprocessor,
6041 recompute=args.recompute)
6042 sys.stdout.flush()
6043 if planned != 0:
6044 result = None
6045 try:
6046 result = subprocess.run(command)
6047 finally:
6048 if args.post_recipe:
6049 finish_post_job(args.run_dir, args.post_recipe,
6050 exited_cleanly=result is not None and result.returncode == 0)
6051 sys.stdout.flush()
6052 if result.returncode != 0:
6053 return int(result.returncode)
6054 else:
6055 print('[INFO] Every requested step with a committed checkpoint is up to date; '
6056 'the postprocessor is not launched.')
6057 if args.success_command_json:
6058 success_command = json.loads(args.success_command_json)
6059 if not isinstance(success_command, list) or not success_command:
6060 print('[FATAL] Invalid post success command.', file=sys.stderr)
6061 return 2
6062 result = subprocess.run([str(token) for token in success_command])
6063 return int(result.returncode)
6064 return 0
6065 finally:
6066 try:
6067 os.remove(args.metadata_file)
6068 except FileNotFoundError:
6069 pass
6070 os.close(fd)
6071
6072
6073if __name__ == '__main__':
6074 raise SystemExit(main())
6075"""
6076
6077
6078def ensure_post_lock_wrapper(run_dir: str) -> str:
6079 """!
6080 @brief Ensure the lock wrapper exists for a run directory and return its path.
6081 @param[in] run_dir Argument passed to `ensure_post_lock_wrapper()`.
6082 @return Value returned by `ensure_post_lock_wrapper()`.
6083 """
6084 paths = get_post_lock_paths(run_dir)
6085 wrapper_path = paths['wrapper_path']
6087 existing_content = None
6088 os.makedirs(os.path.dirname(wrapper_path), exist_ok=True)
6089 if os.path.isfile(wrapper_path):
6090 with open(wrapper_path, 'r', encoding='utf-8', errors='replace') as f:
6091 existing_content = f.read()
6092 if existing_content != content:
6093 with open(wrapper_path, 'w', encoding='utf-8') as f:
6094 f.write(content)
6095 os.chmod(wrapper_path, 0o755)
6096 return wrapper_path
6097
6098
6099def build_post_locked_command(run_dir: str, recipe_fingerprint: str, wrapped_command: list,
6100 create_wrapper: bool = True,
6101 success_command: "list | None" = None,
6102 post_recipe: "str | None" = None,
6103 postprocessor: "str | None" = None,
6104 recompute: bool = False) -> "tuple[list, dict]":
6105 """!
6106 @brief Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
6107 @param[in] run_dir Argument passed to `build_post_locked_command()`.
6108 @param[in] recipe_fingerprint Argument passed to `build_post_locked_command()`.
6109 @param[in] wrapped_command Argument passed to `build_post_locked_command()`.
6110 @param[in] create_wrapper Argument passed to `build_post_locked_command()`.
6111 @param[in] success_command Optional serial command run under the same lock after success.
6112 @param[in] post_recipe Recipe whose steps the wrapper plans at launch; None runs the
6113 command as given.
6114 @param[in] postprocessor Postprocessor the command launches, recorded with its output.
6115 @param[in] recompute Regenerate every step whose checkpoint is committed.
6116 @return Value returned by `build_post_locked_command()`.
6117 """
6118 lock_paths = get_post_lock_paths(run_dir)
6119 wrapper_path = ensure_post_lock_wrapper(run_dir) if create_wrapper else lock_paths['wrapper_path']
6120 command = [
6121 sys.executable, wrapper_path,
6122 '--lock-file', lock_paths['lock_file'],
6123 '--metadata-file', lock_paths['metadata_file'],
6124 '--run-dir', run_dir,
6125 '--recipe-fingerprint', recipe_fingerprint,
6126 ]
6127 if post_recipe:
6128 command += ['--post-recipe', os.path.abspath(post_recipe),
6129 '--postprocessor', os.path.abspath(postprocessor)]
6130 if recompute:
6131 command.append('--recompute')
6132 if success_command:
6133 command += ['--success-command-json', json.dumps(list(success_command))]
6134 command += ['--'] + list(wrapped_command)
6135 return command, lock_paths
6136
6137
6138def run_post_finalize_locked(run_dir: str, recipe_fingerprint: str,
6139 finalize_command: list) -> None:
6140 """!
6141 @brief Refresh a collection-only result under the ordinary post writer lock.
6142 @param[in] run_dir Run whose post writer lock is acquired.
6143 @param[in] recipe_fingerprint Recipe identity recorded in lock metadata.
6144 @param[in] finalize_command Serial collection finalizer command.
6145 @return None.
6146 """
6147 if not finalize_command:
6148 return
6149 command, _ = build_post_locked_command(
6150 run_dir, recipe_fingerprint, [sys.executable, "-c", "pass"],
6151 create_wrapper=True, success_command=finalize_command,
6152 )
6153 result = subprocess.run(command, cwd=run_dir)
6154 if result.returncode != 0:
6155 raise ValueError(
6156 f"Post finalization (ParaView series, reducers) failed with exit code {result.returncode}."
6157 )
6158
6159
6161 run_dir: str,
6162 run_id: str,
6163 case_cfg: dict,
6164 monitor_cfg: dict,
6165 post_cfg: dict,
6166 recompute: bool = False,
6167 allow_source_frontier_scan: bool = True,
6168 postprocessor_sha: "str | None" = None,
6169) -> dict:
6170 """!
6171 @brief Decide which steps of a recipe's window one invocation processes.
6172 @details Every requested step is classified by classify_post_step(): valid output is
6173 kept, and missing or stale output is (re)made once its checkpoint is
6174 committed. Steps need not be contiguous. When the solver stage has not run
6175 yet the source scan is deferred and every step without valid output is
6176 planned; the post job plans again when it starts (prepare_post_job()).
6177 @param[in] run_dir Run being post-processed.
6178 @param[in] run_id Run identity recorded in the recipe state.
6179 @param[in] case_cfg Parsed case configuration.
6180 @param[in] monitor_cfg Parsed monitor configuration.
6181 @param[in] post_cfg Parsed post recipe.
6182 @param[in] recompute Regenerate every step whose checkpoint is committed.
6183 @param[in] allow_source_frontier_scan False when the solver stage has not produced output yet.
6184 @param[in] postprocessor_sha Hash of the postprocessor that would run, used only to
6185 report valid output another build made.
6186 @return Plan mapping; see the keys of the returned dictionary.
6187 """
6188 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
6189 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
6190 requested_start_step, requested_end_step, step_interval = resolve_post_requested_window(post_cfg, case_cfg)
6191 owned_start_step = resolve_post_owned_start(
6192 run_dir, post_cfg, requested_start_step, step_interval
6193 )
6194 resolved_source_dir = _resolve_post_source_directory_preview(run_dir, monitor_cfg, post_cfg)
6195 resolved_post_cfg = prepare_effective_post_config(post_cfg, resolved_source_dir)
6196 recipe_cfg = build_post_recipe_config(resolved_post_cfg, monitor_cfg)
6197 recipe_signature, recipe_fingerprint = compute_post_recipe_fingerprint(recipe_cfg)
6198 state_path = get_post_resume_state_path(run_dir, resolved_post_cfg)
6199 records, _ = _read_post_records(state_path)
6200
6201 source_frontier_deferred = not allow_source_frontier_scan
6202 available = None
6203 source_frontier_step = None
6204 source_frontier_diagnostic = None
6205 if not source_frontier_deferred:
6207 resolved_source_dir, require_particles=_post_needs_particle_source(resolved_post_cfg)
6208 )
6209 source_frontier_info = detect_post_source_frontier(
6210 resolved_source_dir, monitor_cfg, resolved_post_cfg,
6211 owned_start_step, requested_end_step, step_interval,
6212 )
6213 source_frontier_step = source_frontier_info['frontier_step']
6214 source_frontier_diagnostic = source_frontier_info['diagnostic']
6215
6216 outputs = post_step_outputs(run_dir, resolved_post_cfg, monitor_cfg)
6217 process, unavailable, reasons, adopted = [], [], {}, {}
6218 current = other_build = 0
6219 for step in _iter_post_steps(owned_start_step, requested_end_step, step_interval):
6220 record = records.get(str(step))
6221 found = outputs(step)
6222 files = None if found is None else found[0] + found[1]
6223 commit = _checkpoint_commit(resolved_source_dir, step)
6224 reason = classify_post_step(record, files, commit, recipe_fingerprint, recompute)
6225 if reason is None:
6226 current += 1
6227 if record is None:
6228 record = adopted[step] = {
6229 "recipe": recipe_fingerprint,
6230 "checkpoint": commit[0] if commit else None,
6231 "postprocessor": None,
6232 }
6233 if postprocessor_sha and record.get("postprocessor") != postprocessor_sha:
6234 other_build += 1
6235 elif available is None or step in available:
6236 process.append(step)
6237 reasons[reason] = reasons.get(reason, 0) + 1
6238 else:
6239 unavailable.append(step)
6240
6241 skip_reason = None
6242 if not process:
6243 if not unavailable:
6244 skip_reason = 'already-complete-window'
6245 elif current:
6246 skip_reason = 'already-caught-up-to-current-source-frontier'
6247 else:
6248 skip_reason = 'nothing-available-yet'
6249 effective_start_step = process[0] if process else None
6250 effective_end_step = process[-1] if process else None
6251 effective_post_cfg = None
6252 if skip_reason is None:
6253 effective_post_cfg = prepare_effective_post_config(
6254 post_cfg, resolved_source_dir,
6255 start_step=effective_start_step, end_step=effective_end_step,
6256 )
6257
6258 return {
6259 'run_id': run_id,
6260 'recompute': bool(recompute),
6261 'requested_start_step': requested_start_step,
6262 'requested_end_step': requested_end_step,
6263 'owned_start_step': owned_start_step,
6264 'step_interval': step_interval,
6265 'source_data_directory': resolved_source_dir,
6266 'recipe_config': recipe_cfg,
6267 'recipe_signature': recipe_signature,
6268 'recipe_fingerprint': recipe_fingerprint,
6269 'resume_state_path': state_path,
6270 'steps_to_process': process,
6271 'steps_unavailable': unavailable,
6272 'steps_current': current,
6273 'steps_other_build': other_build,
6274 'process_reasons': reasons,
6275 'adopted_records': adopted,
6276 'source_frontier_step': source_frontier_step,
6277 'source_frontier_diagnostic': source_frontier_diagnostic,
6278 'source_frontier_deferred': source_frontier_deferred,
6279 'source_committed_count': None if available is None else len(available),
6280 'source_latest_step': max(available) if available else None,
6281 'effective_start_step': effective_start_step,
6282 'effective_end_step': effective_end_step,
6283 'skip_reason': skip_reason,
6284 'resolved_post_cfg': resolved_post_cfg,
6285 'effective_post_cfg': effective_post_cfg,
6286 # The lock build_post_locked_command() takes: one post writer per run.
6287 'lock_paths': get_post_lock_paths(run_dir),
6288 }
6289
6290
6291POST_PROCESS_REASON_LABELS = {
6292 "missing": "no output yet",
6293 "recipe": "recipe changed",
6294 "checkpoint": "checkpoint changed",
6295 "recompute": "--recompute",
6296}
6297
6298
6299def report_post_plan(plan: dict) -> None:
6300 """!
6301 @brief Print which steps a post plan processes, keeps, and waits for.
6302 @param[in] plan Plan from build_post_execution_plan().
6303 @return None.
6304 """
6305 process = plan['steps_to_process']
6306 if plan['source_frontier_deferred']:
6307 print(f"[INFO] Post steps: {len(process)} planned; the post job decides when it starts, "
6308 "after the solver has written them.")
6309 return
6310 why = ", ".join(f"{count} {POST_PROCESS_REASON_LABELS.get(reason, reason)}"
6311 for reason, count in sorted(plan['process_reasons'].items()))
6312 span = f" ({process[0]}..{process[-1]}; {why})" if process else ""
6313 print(f"[INFO] Post steps: {len(process)} to process{span}, {plan['steps_current']} up to date, "
6314 f"{len(plan['steps_unavailable'])} without a committed checkpoint.")
6315 if plan['steps_other_build']:
6316 print(f"[INFO] {plan['steps_other_build']} up-to-date step(s) were made by another or an "
6317 "unrecorded postprocessor build and are kept; --recompute regenerates them.")
6318
6319
6320def get_post_step_list_path(run_dir: str, post_cfg: dict) -> str:
6321 """!
6322 @brief Return the file naming the steps a launched post job processes.
6323 @param[in] run_dir Run directory.
6324 @param[in] post_cfg Runtime recipe with canonical paths.
6325 @return Absolute path; a companion `<path>.json` holds what recording needs.
6326 """
6327 return os.path.abspath(os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.steps"))
6328
6329
6330def _load_post_job_configs(run_dir: str, post_recipe_path: str) -> "tuple[dict, dict, dict]":
6331 """!
6332 @brief Load the configuration a post job needs from the run it runs in.
6333 @param[in] run_dir Run directory.
6334 @param[in] post_recipe_path The recipe the job was staged with.
6335 @return (case configuration, monitor configuration, runtime recipe with canonical paths).
6336 """
6337 case_path, monitor_path, _ = auto_identify_run_inputs(os.path.join(run_dir, "config"))
6338 if not (case_path and monitor_path):
6339 raise ValueError(f"Could not identify the case and monitor files of {run_dir}.")
6340 post_cfg, _ = apply_canonical_post_paths(read_yaml_file(post_recipe_path), run_dir)
6341 return read_yaml_file(case_path), read_yaml_file(monitor_path), post_cfg
6342
6343
6344def prepare_post_job(run_dir: str, post_recipe_path: str, postprocessor: str,
6345 recompute: bool = False) -> int:
6346 """!
6347 @brief Decide, as a post job starts, which steps it processes.
6348 @details Runs under the post lock immediately before the postprocessor, so a job
6349 staged long before it runs still skips output produced in the meantime and
6350 sees checkpoints the solver has written since. Writes the step list the
6351 postprocessor reads, and records what finish_post_job() needs.
6352 @param[in] run_dir Run directory.
6353 @param[in] post_recipe_path The recipe the job was staged with.
6354 @param[in] postprocessor Postprocessor executable the job launches.
6355 @param[in] recompute Regenerate every step whose checkpoint is committed.
6356 @return Number of steps to process; 0 means the postprocessor need not run.
6357 """
6358 run_dir = os.path.abspath(run_dir)
6359 case_cfg, monitor_cfg, post_cfg = _load_post_job_configs(run_dir, post_recipe_path)
6360 try:
6361 run_id = read_artifact_identity(run_dir)["run_id"]
6362 except Exception:
6363 run_id = os.path.basename(run_dir)
6364 sha = _file_sha256(postprocessor)
6365 plan = build_post_execution_plan(run_dir, run_id, case_cfg, monitor_cfg, post_cfg,
6366 recompute=recompute, postprocessor_sha=sha)
6367 report_post_plan(plan)
6368 persist_post_resume_state(run_dir, plan, steps=plan['adopted_records'])
6369 list_path = get_post_step_list_path(run_dir, post_cfg)
6370 steps = plan['steps_to_process'] if plan['skip_reason'] is None else []
6371 if not steps:
6372 for path in (list_path, list_path + ".json"):
6373 if os.path.exists(path):
6374 os.remove(path)
6375 return 0
6376 outputs = post_step_outputs(run_dir, plan['resolved_post_cfg'], monitor_cfg)
6377 before = {}
6378 for step in steps:
6379 found = outputs(step)
6380 before[str(step)] = {path: os.stat(path).st_mtime_ns
6381 for path in (found[0] + found[1] if found else [])}
6382 write_json_file(list_path + ".json", {
6383 "postprocessor": sha,
6384 "outputs_before": before,
6385 "plan": {key: plan[key] for key in (
6386 "run_id", "recipe_fingerprint", "recipe_signature", "requested_start_step",
6387 "requested_end_step", "step_interval", "source_data_directory", "resume_state_path")},
6388 })
6389 with open(list_path, "w", encoding="utf-8") as stream:
6390 stream.write("".join(f"{step}\n" for step in steps))
6391 return len(steps)
6392
6393
6394def finish_post_job(run_dir: str, post_recipe_path: str, exited_cleanly: bool = False) -> int:
6395 """!
6396 @brief Record, after a post job ends, which steps it produced.
6397 @details Runs whether or not the postprocessor succeeded, so the steps a failed or
6398 interrupted job completed are kept. A step is credited only when all its
6399 output exists and was written by this job.
6400 @param[in] run_dir Run directory.
6401 @param[in] post_recipe_path The recipe the job was staged with.
6402 @param[in] exited_cleanly Whether the postprocessor exited with status zero; a clean
6403 exit that left an expected output unwritten is reported.
6404 @return Number of steps recorded.
6405 """
6406 run_dir = os.path.abspath(run_dir)
6407 _case_cfg, monitor_cfg, post_cfg = _load_post_job_configs(run_dir, post_recipe_path)
6408 list_path = get_post_step_list_path(run_dir, post_cfg)
6409 job = _read_json_if_exists(list_path + ".json")
6410 if not isinstance(job, dict):
6411 return 0
6412 plan = job["plan"]
6413 source = plan["source_data_directory"]
6414 runtime_cfg = prepare_effective_post_config(post_cfg, source)
6415 outputs = post_step_outputs(run_dir, runtime_cfg, monitor_cfg)
6416 records = {}
6417 incomplete = []
6418 for step_text, before in job["outputs_before"].items():
6419 step = int(step_text)
6420 found = outputs(step)
6421 if found is None:
6422 incomplete.append(step)
6423 continue
6424 # A shared CSV changes with every step, so it proves a step was written only
6425 # when the step has no file of its own.
6426 written = found[0] or found[1]
6427 if not written or any(before.get(path) == os.stat(path).st_mtime_ns for path in written):
6428 continue
6429 commit = _checkpoint_commit(source, step)
6430 records[step] = {
6431 "recipe": plan["recipe_fingerprint"],
6432 "checkpoint": commit[0] if commit else None,
6433 "postprocessor": job["postprocessor"],
6434 }
6435 persist_post_resume_state(run_dir, plan, steps=records)
6436 for path in (list_path, list_path + ".json"):
6437 if os.path.exists(path):
6438 os.remove(path)
6439 print(f"[INFO] Post job recorded {len(records)} of {len(job['outputs_before'])} planned step(s).")
6440 # A postprocessor that does not know a recipe key ignores it and still exits
6441 # cleanly, so a step can finish without an output the recipe expects. That is
6442 # what a pinned executable older than the recipe looks like.
6443 if incomplete and exited_cleanly:
6444 try:
6445 executable = run_executable_path(run_dir, "postprocessor")
6446 except ValueError:
6447 executable = "the run's postprocessor"
6448 print(f"[WARNING] The postprocessor exited cleanly, but {len(incomplete)} planned step(s) "
6449 f"lack an output this recipe expects (first: step {min(incomplete)}). It may "
6450 f"predate a requested output: {executable} ignores recipe keys it does not know. "
6451 "Check its build with 'picurv version', or post-process a branch of the run "
6452 "built from the current source.", file=sys.stderr)
6453 return len(records)
6454
6455
6456def needs_restart_source(case_cfg: dict, solver_cfg: dict) -> bool:
6457 """!
6458 @brief Return True when the solver requires restart data from disk.
6459 @details Correctly identifies that analytical + init + start_step > 0 does NOT
6460 need a restart source (C code never reads from restart_dir in that case).
6461 @param[in] case_cfg Parsed case YAML dictionary.
6462 @param[in] solver_cfg Parsed solver YAML dictionary.
6463 @return True if a restart source (--restart-from or --continue) is required.
6464 """
6465 try:
6466 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
6467 except (TypeError, ValueError):
6468 start_step = 0
6469 eulerian_source = str(
6470 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
6471 ).strip().lower()
6472 particle_restart_mode = str(
6473 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
6474 ).strip().lower()
6475 euler_needs = (eulerian_source == "load") or (eulerian_source == "solve" and start_step > 0)
6476 particle_needs = (particle_restart_mode == "load")
6477 return euler_needs or particle_needs
6478
6479
6480def resolve_run_output_dir(run_dir: str, monitor_cfg: dict) -> str:
6481 """!
6482 @brief Resolve the output data directory within a run directory.
6483 @param[in] run_dir Path to the run directory.
6484 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6485 @return Absolute path to the output directory.
6486 """
6487 del monitor_cfg
6488 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["output"]))
6489
6490
6491def resolve_run_restart_dir(run_dir: str, monitor_cfg: dict) -> str:
6492 """!
6493 @brief Resolve the restart staging directory within a run directory.
6494 @param[in] run_dir Path to the run directory.
6495 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6496 @return Absolute path to the restart directory.
6497 """
6498 del monitor_cfg
6499 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["restart"]))
6500
6501
6502def compute_physical_case_identity(case_cfg: dict) -> str:
6503 """!
6504 @brief Compute the hidden identity used to guard in-place continuation.
6505 @details Run length/timestep controls, the MPI domain decomposition, and particle
6506 load-vs-init policy do not define the physical case. All other case.yml
6507 content does. Checkpoints are decomposition-independent, so a run may
6508 continue on a different rank layout.
6509 @param[in] case_cfg Parsed case configuration.
6510 @return Lowercase SHA-256 identity of normalized physical-case content.
6511 """
6512 normalized = copy.deepcopy(case_cfg)
6513 if isinstance(normalized, dict):
6514 normalized.pop("run_control", None)
6515 grid = normalized.get("grid")
6516 if isinstance(grid, dict):
6517 for key in GRID_DA_PROCESSOR_KEYS:
6518 grid.pop(key, None)
6519 if isinstance(grid.get("programmatic_settings"), dict):
6520 for key in GRID_DA_PROCESSOR_KEYS:
6521 grid["programmatic_settings"].pop(key, None)
6522 particles = (((normalized.get("models") or {}).get("physics") or {}).get("particles"))
6523 if isinstance(particles, dict):
6524 particles.pop("restart_mode", None)
6525 payload = json.dumps(
6526 normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False
6527 ).encode("utf-8")
6528 return hashlib.sha256(payload).hexdigest()
6529
6530
6531def validate_continue_case_identity(run_dir: str, case_cfg: dict) -> None:
6532 """!
6533 @brief Reject in-place continuation when the physical case has changed.
6534 @param[in] run_dir Existing run directory being continued.
6535 @param[in] case_cfg Newly requested case configuration.
6536 @return None.
6537 """
6538 saved_case_path = os.path.join(run_dir, "config", "case.yml")
6539 if not os.path.isfile(saved_case_path):
6540 raise ValueError(f"--continue run is missing its saved case.yml: {saved_case_path}")
6541 saved_case = read_yaml_file(saved_case_path)
6543 raise ValueError(
6544 "--continue cannot change the physical case. Change only run_control, the "
6545 "da_processors layout, solver.yml, monitor.yml, or post.yml; use --restart-from "
6546 "for a new case branch."
6547 )
6548
6549
6550def populate_restart_directory(source_output: str, target_restart: str, start_step: int,
6551 monitor_cfg: dict, end_step: "int | None" = None,
6552 materialize: bool = True):
6553 """!
6554 @brief Atomically materialize an immutable checkpoint interval into a run.
6555 @param[in] source_output Path to the source output directory containing checkpoint data.
6556 @param[in] target_restart Path to the target restart directory to populate.
6557 @param[in] start_step First checkpoint step to materialize.
6558 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6559 @param[in] end_step Optional inclusive last checkpoint step.
6560 @param[in] materialize Whether to copy the bundles, or only validate and resolve
6561 the path a real run would use. The dry-run planner needs the second:
6562 it reports what a run would refuse, and promises to write nothing.
6563 @return Canonical restart root containing committed checkpoint bundles.
6564 """
6565 del monitor_cfg
6566 checkpoints_root = os.path.join(os.path.abspath(target_restart), "checkpoints")
6567 if materialize:
6568 os.makedirs(checkpoints_root, exist_ok=True)
6569 final_step = start_step if end_step is None else end_step
6570 if final_step < start_step:
6571 raise ValueError("Restart checkpoint interval end must not precede its start.")
6572 for step in range(start_step, final_step + 1):
6573 source = validate_committed_checkpoint(source_output, step)["bundle"]
6574 if not materialize:
6575 continue
6576 destination = os.path.join(
6577 checkpoints_root, f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}"
6578 )
6579 if os.path.isdir(destination):
6580 validate_committed_checkpoint(destination, step)
6581 print(f"[INFO] Reusing committed restart bundle: {destination}")
6582 continue
6583 temporary = os.path.join(
6584 checkpoints_root,
6585 f".step_{step:0{CHECKPOINT_STEP_WIDTH}d}.copying.{os.getpid()}",
6586 )
6587 if os.path.exists(temporary):
6588 raise ValueError(f"Restart staging path already exists: {temporary}")
6589 try:
6590 os.makedirs(temporary)
6591 for current, dirnames, filenames in os.walk(source):
6592 relative = os.path.relpath(current, source)
6593 target_dir = temporary if relative == "." else os.path.join(temporary, relative)
6594 os.makedirs(target_dir, exist_ok=True)
6595 for dirname in dirnames:
6596 os.makedirs(os.path.join(target_dir, dirname), exist_ok=True)
6597 for filename in filenames:
6599 os.path.join(current, filename), os.path.join(target_dir, filename)
6600 )
6601 validate_committed_checkpoint(temporary, step)
6602 os.replace(temporary, destination)
6603 except Exception:
6604 if os.path.isdir(temporary):
6605 shutil.rmtree(temporary)
6606 raise
6607 print(f"[INFO] Materialized committed restart bundle for step {step}: {destination}")
6608 return os.path.abspath(target_restart)
6609
6610
6611def validate_eulerian_checkpoint(source_dir: str, step: int, monitor_cfg: dict):
6612 """!
6613 @brief Validate the mandatory Eulerian field set required by `ReadSimulationFields()`.
6614 @param[in] source_dir Root directory containing the Eulerian subdirectory.
6615 @param[in] step Checkpoint step to validate.
6616 @param[in] monitor_cfg Monitor configuration defining the Eulerian subdirectory.
6617 @return Validated checkpoint description.
6618 @throws ValueError if any mandatory Eulerian field is absent.
6619 """
6620 del monitor_cfg
6621 return validate_committed_checkpoint(source_dir, step)
6622
6623
6624def detect_last_checkpoint_step(output_dir: str):
6625 """!
6626 @brief Scan output directory for the highest step number available.
6627 @details Ignores incomplete temporary directories and invalid bundles.
6628 @param[in] output_dir Path to the output directory.
6629 @return The highest step number found, or None if no checkpoints exist.
6630 """
6631 steps = _scan_committed_checkpoint_steps(output_dir)
6632 return max(steps) if steps else None
6633
6634
6635def detect_case_completion_status(run_dir: str, monitor_cfg: dict, target_final_step: int) -> dict:
6636 """!
6637 @brief Determine whether a study case is complete, partially complete, or empty.
6638 @param[in] run_dir Path to the case run directory.
6639 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6640 @param[in] target_final_step The step number the case should reach for completion.
6641 @return Dictionary with keys 'last_step' (int or None), 'target_step' (int),
6642 and 'status' ('complete', 'partial', or 'empty').
6643 """
6644 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
6645 last_step = detect_last_checkpoint_step(output_dir)
6646 if last_step is not None and last_step >= target_final_step:
6647 status = "complete"
6648 elif last_step is not None:
6649 status = "partial"
6650 else:
6651 status = "empty"
6652 return {"last_step": last_step, "target_step": target_final_step, "status": status}
6653
6654
6655def validate_load_mode_step_range(source_output: str, start_step: int, total_steps: int, monitor_cfg: dict):
6656 """!
6657 @brief Validate that all required eulerian step files exist for "load" mode.
6658 @details Checks that ufield files exist for every step from start_step through
6659 start_step + total_steps (inclusive). Reports missing steps clearly.
6660 @param[in] source_output Path to the output directory containing eulerian data.
6661 @param[in] start_step First step that will be loaded.
6662 @param[in] total_steps Number of steps to run.
6663 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6664 """
6665 missing = []
6666 for step in range(start_step, start_step + total_steps + 1):
6667 try:
6668 validate_eulerian_checkpoint(source_output, step, monitor_cfg)
6669 except CheckpointIndexWidthError:
6670 raise
6671 except ValueError:
6672 missing.append(step)
6673
6674 if missing:
6675 sample = missing[:3] + (["..."] if len(missing) > 6 else []) + missing[-3:]
6676 raise ValueError(
6677 f"Eulerian 'load' mode: {len(missing)} committed checkpoint(s) missing in {source_output}. "
6678 f"Missing steps include: {sample}"
6679 )
6680
6681
6682def validate_particle_checkpoint(source_dir: str, start_step: int, monitor_cfg: dict):
6683 """!
6684 @brief Validate that particle checkpoint files exist for the given step.
6685 @details Checks that at least a position file exists at the expected step in
6686 the particle subdirectory.
6687 @param[in] source_dir Path to the directory containing the particle subdirectory.
6688 @param[in] start_step The step number whose particle checkpoint is expected.
6689 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6690 @return Validated checkpoint description.
6691 """
6692 del monitor_cfg
6693 return validate_committed_checkpoint(source_dir, start_step, require_particles=True)
6694
6695
6696def read_monitor_from_run(run_dir: str) -> dict:
6697 """!
6698 @brief Read the monitor.yml from a run directory's config/ subdirectory.
6699 @param[in] run_dir Path to the run directory.
6700 @return Parsed monitor YAML dictionary.
6701 """
6702 monitor_path = os.path.join(run_dir, "config", "monitor.yml")
6703 if not os.path.isfile(monitor_path):
6704 raise ValueError(f"Run directory is missing config/monitor.yml: {monitor_path}")
6705 return read_yaml_file(monitor_path)
6706
6707
6708def resolve_latest_restart_run(case_cfg: dict, case_path: str, start_step: int) -> str:
6709 """!
6710 @brief Select the newest local workspace run compatible with a requested restart.
6711 @param[in] case_cfg Current case configuration.
6712 @param[in] case_path Current case path.
6713 @param[in] start_step Required committed checkpoint.
6714 @return Absolute source run directory.
6715 """
6716 workspace_root = find_workspace_root(case_path, os.getcwd())
6717 if not workspace_root:
6718 raise ValueError("--from latest requires an initialized workspace.")
6719 current_graph = build_case_asset_graph(case_cfg, case_path)
6720 current_grid = next(
6721 (item for item in current_graph.get("providers", []) if item.get("kind") == "grid"),
6722 None,
6723 )
6724 candidates = []
6725 for candidate in Path(workspace_root, "runs").iterdir():
6726 if not candidate.is_dir():
6727 continue
6728 manifest = _read_json_if_exists(str(candidate / "manifest.json")) or {}
6729 lock = read_yaml_file(str(candidate / "inputs" / "assets.lock.yml")) \
6730 if (candidate / "inputs" / "assets.lock.yml").is_file() else {}
6731 locked_grid = (lock.get("assets") or {}).get("grid")
6732 runtime_grid = (lock.get("runtime_providers") or {}).get("grid")
6733 if current_grid:
6734 recorded_hash = None
6735 if isinstance(locked_grid, dict):
6736 recorded_hash = locked_grid.get("provider_spec_sha256")
6737 elif isinstance(runtime_grid, dict):
6738 recorded_hash = runtime_grid.get("provider_spec_sha256")
6739 if recorded_hash and recorded_hash != current_grid.get("spec_sha256"):
6740 continue
6741 output_root = candidate / CANONICAL_RUN_PATHS["output"]
6742 try:
6743 validate_committed_checkpoint(str(output_root), start_step)
6744 except CheckpointIndexWidthError:
6745 raise
6746 except (OSError, ValueError):
6747 continue
6748 ordering = manifest.get("updated_at") or manifest.get("created_at") or ""
6749 candidates.append((ordering, candidate.stat().st_mtime_ns, str(candidate)))
6750 if not candidates:
6751 raise ValueError(
6752 f"No local workspace run has a compatible committed checkpoint at step {start_step}. "
6753 "Restore a run from storage first, or name one explicitly with --from <run-dir>."
6754 )
6755 candidates.sort(reverse=True)
6756 selected = candidates[0][2]
6757 print(f"[INFO] Selected latest compatible restart run: {os.path.relpath(selected, workspace_root)}")
6758 return selected
6759
6760
6761def resolve_restart_source(args, case_cfg: dict, solver_cfg: dict, monitor_cfg: dict,
6762 run_dir: str, materialize: bool = True):
6763 """!
6764 @brief Resolve the restart source directory based on --restart-from or --continue CLI flags.
6765 @details Implements the full restart resolution logic including smart resolution for
6766 --continue (checks restart/ first for user-curated data, falls back to output/)
6767 and direct reference for eulerian "load" mode.
6768 @param[in] args Parsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
6769 @param[in] case_cfg Parsed case YAML dictionary.
6770 @param[in] solver_cfg Parsed solver YAML dictionary.
6771 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6772 @param[in] run_dir Path to the current run directory.
6773 @param[in] materialize Whether to copy restart bundles into the run. The dry-run
6774 planner passes False: it still validates and resolves, but writes nothing.
6775 @return Tuple of (restart_source_dir, continue_mode, lineage) where restart_source_dir
6776 is the resolved path (or None), continue_mode is a boolean, and lineage is the
6777 branch provenance record from `build_run_lineage()`, or None when this run did
6778 not branch from another.
6779 """
6780 try:
6781 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
6782 except (TypeError, ValueError):
6783 start_step = 0
6784 try:
6785 total_steps = int(case_cfg.get("run_control", {}).get("total_steps", 0) or 0)
6786 except (TypeError, ValueError):
6787 total_steps = 0
6788
6789 eulerian_source = str(
6790 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
6791 ).strip().lower()
6792
6793 particle_restart_mode = str(
6794 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
6795 ).strip().lower()
6796 particle_needs = (particle_restart_mode == "load")
6797
6798 requires_source = needs_restart_source(case_cfg, solver_cfg)
6799 restart_from = getattr(args, 'restart_from', None)
6800 continue_run = getattr(args, 'continue_run', False)
6801
6802 if restart_from and continue_run:
6803 raise ValueError("--restart-from and --continue are mutually exclusive.")
6804
6805 # The named run must exist before its start step can mean anything.
6806 if continue_run and getattr(args, 'run_dir', None) and not os.path.isdir(args.run_dir):
6807 raise ValueError(f"--run-dir does not exist: {os.path.abspath(args.run_dir)}")
6808 if continue_run and start_step <= 0:
6809 raise ValueError(
6810 "--continue with --solve requires run_control.start_step > 0; "
6811 "start_step=0 is a fresh start. Omit --continue to start a fresh run."
6812 )
6813
6814 if restart_from:
6815 requested_restart_source = str(restart_from)
6816 if str(restart_from).strip().lower() == "latest":
6817 restart_from = resolve_latest_restart_run(
6818 case_cfg, getattr(args, "case", "case.yml"), start_step
6819 )
6820 # A branch may follow a different physical trajectory than the samples already
6821 # collected, so the retention of the parent's windows is the user's decision and
6822 # not a default's. Silently resetting reports a sample count that describes a
6823 # shorter average than the user expects; silently carrying averages two
6824 # different flows together. Neither is safe to guess, so the flag is required
6825 # whenever there are windows to decide about.
6826 requested_statistics_state = getattr(args, "statistics_state", None)
6827 statistics_enabled = normalize_field_statistics_config(monitor_cfg or {})["enabled"]
6828 if requested_statistics_state is None:
6829 if statistics_enabled:
6830 raise ValueError(
6831 "A branched restart of a run with field_statistics.enabled: true must "
6832 "state what happens to the parent's accumulated windows. Pass "
6833 "--statistics-state reset to discard them and start the averages over, "
6834 "or --statistics-state carry to resume compatible saved window state."
6835 )
6836 requested_statistics_state = "reset"
6837 statistics_state = str(requested_statistics_state).lower()
6838 if statistics_state == "carry" and not statistics_enabled:
6839 raise ValueError("--statistics-state carry requires field_statistics.enabled: true.")
6840 # Carried windows are read from the source checkpoint even when nothing else is:
6841 # an analytical Eulerian source restores no fields, but its statistics still
6842 # live in that bundle, and an unstaged restart directory fails at start-up.
6843 if statistics_state == "carry":
6844 requires_source = True
6845
6846 # === MODE 1: New run, restart from another run ===
6847 source_run = os.path.abspath(restart_from)
6848 if not os.path.isdir(source_run):
6849 raise ValueError(f"--restart-from run directory does not exist: {source_run}")
6850 try:
6851 require_storage_payload_local(source_run, "--restart-from", checkpoint=start_step)
6852 except StorageError as exc:
6853 raise ValueError(str(exc)) from exc
6854 source_monitor = read_monitor_from_run(source_run)
6855 source_output = resolve_run_output_dir(source_run, source_monitor)
6856 if not os.path.isdir(source_output):
6857 raise ValueError(f"Source output directory does not exist: {source_output}")
6858 lineage = build_run_lineage(
6859 source_run, start_step,
6860 workspace_root=find_workspace_root(run_dir),
6861 statistics_state=statistics_state,
6862 requested_source=requested_restart_source,
6863 )
6864
6865 if not requires_source:
6866 # R7: analytical + init — warn that --restart-from is unused
6867 print(
6868 "[WARNING] --restart-from has no effect: an analytical flow with freshly "
6869 "initialized particles reads no restart data.",
6870 file=sys.stderr,
6871 )
6872 return None, False, None
6873
6874 if eulerian_source == "load":
6875 # A load-mode run may consume a complete saved sequence. Materialize it
6876 # into this run so storage never depends on another run remaining local.
6877 validate_load_mode_step_range(source_output, start_step, total_steps, source_monitor)
6878 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6879 restart_root = populate_restart_directory(
6880 source_output, target_restart, start_step, monitor_cfg,
6881 end_step=start_step + total_steps, materialize=materialize,
6882 )
6883 if particle_needs:
6885 restart_root if materialize else source_output, start_step, monitor_cfg
6886 )
6887 return restart_root, False, lineage
6888 else:
6889 # Materialize one immutable bundle into the new run's restart input.
6890 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6891 restart_root = populate_restart_directory(
6892 source_output, target_restart, start_step, monitor_cfg,
6893 materialize=materialize,
6894 )
6895 if particle_needs:
6897 restart_root if materialize else source_output, start_step, monitor_cfg
6898 )
6899 return restart_root, False, lineage
6900
6901 elif continue_run:
6902 # === MODE 2: Continue in-place ===
6903 continue_run_dir = getattr(args, 'run_dir', None)
6904 if not continue_run_dir:
6905 raise ValueError(RESTART_RUN_DIR_REQUIRED_MESSAGE)
6906 continue_run_dir = os.path.abspath(continue_run_dir)
6907 try:
6908 require_storage_payload_local(continue_run_dir, "--continue", checkpoint=start_step)
6909 except StorageError as exc:
6910 raise ValueError(str(exc)) from exc
6911 validate_continue_case_identity(continue_run_dir, case_cfg)
6912
6913 source_output = resolve_run_output_dir(continue_run_dir, monitor_cfg)
6914 last_step = detect_last_checkpoint_step(source_output)
6915 # The solver never overwrites a committed checkpoint, so continuing from an earlier
6916 # one would keep the old states after it and mix two histories in one run.
6917 if last_step is not None and start_step < last_step:
6918 raise ValueError(
6919 f"--continue starts at step {start_step}, but this run already has committed "
6920 f"checkpoints through step {last_step}. Continuing in place from an earlier "
6921 "checkpoint would keep the later ones from the old trajectory. Set "
6922 f"run_control.start_step to {last_step} to extend the run, or branch a new run "
6923 f"from step {start_step} with --restart-from {continue_run_dir}."
6924 )
6925 if last_step is not None and start_step > last_step:
6926 raise ValueError(
6927 f"--continue starts at step {start_step}, but this run's last committed "
6928 f"checkpoint is step {last_step}. Set run_control.start_step to {last_step}."
6929 )
6930
6931 if eulerian_source == "load":
6932 # Preserve the saved sequence under the run's immutable restart input.
6933 validate_load_mode_step_range(source_output, start_step, total_steps, monitor_cfg)
6934 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6935 restart_root = populate_restart_directory(
6936 source_output, target_restart, start_step, monitor_cfg,
6937 end_step=start_step + total_steps, materialize=materialize,
6938 )
6939 if particle_needs:
6941 restart_root if materialize else source_output, start_step, monitor_cfg
6942 )
6943 return restart_root, True, None
6944 elif not requires_source:
6945 # C6: analytical + init — only log-append behavior, no data needed
6946 return None, True, None
6947 else:
6948 # In-place continuation also uses the fixed restart-input home. Reflink or
6949 # hardlink materialization normally makes this metadata-cheap.
6950 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6951 restart_root = populate_restart_directory(
6952 source_output, target_restart, start_step, monitor_cfg,
6953 materialize=materialize,
6954 )
6956 restart_root if materialize else source_output, start_step,
6957 require_particles=particle_needs,
6958 )
6959 return restart_root, True, None
6960
6961 elif requires_source:
6962 raise ValueError(
6963 "Restart data required but no source specified. Use:\n"
6964 " --restart-from <run_dir> (new run from another run's data)\n"
6965 " --continue --run-dir <run_dir> (resume in same directory)"
6966 )
6967
6968 return None, False, None
6969
6970def absolutize_case_external_paths(case_cfg: dict, case_anchor_path: str):
6971 """!
6972 @brief Convert external grid/generator paths in case config to absolute paths.
6973 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
6974 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
6975 """
6976 # Initialized workspaces deliberately keep portable, root-relative paths in
6977 # every materialized study member. The run/study directory remains below
6978 # the workspace, so normal resolution can still find the owning manifest.
6979 if find_workspace_root(case_anchor_path):
6980 return
6981 grid_cfg = case_cfg.get("grid", {})
6982 if not isinstance(grid_cfg, dict):
6983 return
6984 mode = grid_cfg.get("mode")
6985 if mode == "file":
6986 source_file = grid_cfg.get("source_file")
6987 if isinstance(source_file, str):
6988 grid_cfg["source_file"] = resolve_path(case_anchor_path, source_file)
6989 elif mode == "grid_gen":
6990 gen = grid_cfg.get("generator", {})
6991 if isinstance(gen, dict):
6992 for key in ("script", "config_file"):
6993 val = gen.get(key)
6994 if isinstance(val, str):
6995 gen[key] = resolve_path(case_anchor_path, val)
6996 ic = (case_cfg.get("properties", {}) or {}).get("initial_conditions", {})
6997 if isinstance(ic, dict):
6998 if str(ic.get("mode", "")).strip().lower() == "file":
6999 source_file = ic.get("source_file")
7000 if isinstance(source_file, str):
7001 ic["source_file"] = resolve_path(case_anchor_path, source_file)
7002 elif str(ic.get("generator", "")).strip().lower() == "ic_gen":
7003 params = ic.get("params", {})
7004 if isinstance(params, dict):
7005 for key in ("script", "config_file"):
7006 value = params.get(key)
7007 if isinstance(value, str):
7008 params[key] = resolve_path(case_anchor_path, value)
7009 boundary_conditions = case_cfg.get("boundary_conditions", [])
7010 blocks = boundary_conditions if boundary_conditions and isinstance(boundary_conditions[0], list) else [boundary_conditions]
7011 for block in blocks:
7012 if not isinstance(block, list):
7013 continue
7014 for bc in block:
7015 if not isinstance(bc, dict) or str(bc.get("handler", "")).strip().lower() != "prescribed_flow":
7016 continue
7017 source = ((bc.get("params") or {}).get("source") or {})
7018 if not isinstance(source, dict):
7019 continue
7020 source_type = str(source.get("type", "")).strip().lower()
7021 if source_type == "file":
7022 keys = ("path",)
7023 elif source_type == "generated":
7024 keys = ("script",)
7025 elif source_type == "field_slice":
7026 keys = ("script", "field_file", "grid_file", "source_case")
7027 else:
7028 keys = ()
7029 for key in keys:
7030 value = source.get(key)
7031 if isinstance(value, str):
7032 source[key] = resolve_path(case_anchor_path, value)
7033
7034
7035def prepare_case_for_continuation(run_dir: str, case_id: str, last_step: int,
7036 target_final_step: int, cluster_cfg: dict):
7037 """!
7038 @brief Set up a partially-completed study case for continuation in-place.
7039 @details Updates the case config with new start_step/total_steps, sets particle
7040 restart_mode to 'load' if checkpoint exists, populates the restart
7041 directory, and regenerates the solver control file with continue_mode.
7042 Delegates all restart resolution to resolve_restart_source().
7043 @param[in] run_dir Path to the case run directory.
7044 @param[in] case_id The case identifier (e.g. 'case_0002').
7045 @param[in] last_step The last checkpoint step found in the output directory.
7046 @param[in] target_final_step The step number the case should reach for completion.
7047 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
7048 @return The absolute path to the regenerated control file.
7049 """
7050 config_dir = os.path.join(run_dir, "config")
7051 case_cfg = read_yaml_file(os.path.join(config_dir, "case.yml"))
7052 solver_cfg = read_yaml_file(os.path.join(config_dir, "solver.yml"))
7053 monitor_cfg = read_yaml_file(os.path.join(config_dir, "monitor.yml"))
7054
7055 remaining = target_final_step - last_step
7056 case_cfg["run_control"]["start_step"] = last_step
7057 case_cfg["run_control"]["total_steps"] = remaining
7058 print(f"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
7059
7060 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
7061 particles_cfg = (case_cfg.get("models", {}).get("physics", {}) or {}).get("particles")
7062 checkpoint_has_particles = False
7063 try:
7064 checkpoint_has_particles = validate_committed_checkpoint(
7065 output_dir, last_step, require_particles=True
7066 )["has_particles"]
7067 except CheckpointIndexWidthError:
7068 raise
7069 except ValueError:
7070 checkpoint_has_particles = False
7071 if particles_cfg is not None and checkpoint_has_particles:
7072 current_mode = str(particles_cfg.get("restart_mode", "init")).strip().lower()
7073 if current_mode != "load":
7074 particles_cfg["restart_mode"] = "load"
7075 print(f"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
7076
7077 write_yaml_file(os.path.join(config_dir, "case.yml"), case_cfg)
7078
7079 mock_args = argparse.Namespace(restart_from=None, continue_run=True, run_dir=run_dir)
7080 restart_source_dir, continue_mode, _lineage = resolve_restart_source(
7081 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
7082 )
7083
7084 source_files = {
7085 'Case': os.path.join(config_dir, "case.yml"),
7086 'Solver': os.path.join(config_dir, "solver.yml"),
7087 'Monitor': os.path.join(config_dir, "monitor.yml"),
7088 }
7089 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
7090 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
7091 configs = {
7092 "case": case_cfg, "case_path": source_files['Case'],
7093 "solver": solver_cfg, "solver_path": source_files['Solver'],
7094 "monitor": monitor_cfg, "monitor_path": source_files['Monitor'],
7095 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
7096 "cluster_cfg": cluster_cfg,
7097 }
7098 control_file = generate_solver_control_file(
7099 run_dir, case_id, configs, cluster_tasks, monitor_files,
7100 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
7101 )
7102 print(f"[SUCCESS] {case_id}: regenerated control file for continuation")
7103 return control_file
7104
7105
7106def is_valid_email(email: str) -> bool:
7107 """!
7108 @brief Lightweight email validation for scheduler notifications.
7109 @param[in] email Argument passed to `is_valid_email()`.
7110 @return Value returned by `is_valid_email()`.
7111 """
7112 if not isinstance(email, str):
7113 return False
7114 pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
7115 return re.match(pattern, email.strip()) is not None
7116
7117def normalize_statistics_task(task_name: str) -> str:
7118 """!
7119 @brief Normalizes user-facing statistics task names to C pipeline keywords.
7120 @param[in] task_name Task name from YAML.
7121 @return Canonical keyword accepted by C statistics pipeline.
7122 @throws ValueError if task is unsupported.
7123 """
7124 # Only implemented statistics kernels belong here.
7125 if task_name is None:
7126 raise ValueError("statistics task cannot be None")
7127 normalized = str(task_name).strip().lower().replace("-", "_").replace(" ", "_")
7128 if normalized != "msd":
7129 raise ValueError(f"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
7130 return "ComputeMSD"
7131
7133 """!
7134 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
7135 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
7136 """
7137 for lineno, raw in enumerate(file_obj, start=1):
7138 line = raw.strip()
7139 if not line or line.startswith("#"):
7140 continue
7141 yield lineno, line
7142
7143PICGRID_FLOAT_FORMAT = ".17e"
7144
7145
7146def format_picgrid_coordinate(value: float) -> str:
7147 """!
7148 @brief Format a coordinate with round-trip-safe binary64 precision.
7149 @param[in] value Coordinate value.
7150 @return Formatted coordinate.
7151 """
7152 return format(value, PICGRID_FLOAT_FORMAT)
7153
7154
7155def validate_and_nondimensionalize_picgrid(source_grid: str, dest_grid: str, L_ref: float, expected_nblk: int = None) -> dict:
7156 """!
7157 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
7158 @details Requires canonical PICGRID input with leading "PICGRID" token.
7159 Output is always written in canonical PICGRID format with header and per-block dims.
7160 @param[in] source_grid Input grid file path.
7161 @param[in] dest_grid Output grid file path.
7162 @param[in] L_ref Reference length for non-dimensionalization.
7163 @param[in] expected_nblk Optional expected block count.
7164 @return Summary dictionary with nblk, dims, and total_nodes.
7165 @throws ValueError on malformed grid.
7166 """
7167 if L_ref == 0.0:
7168 raise ValueError("length_ref must be non-zero when processing grid coordinates.")
7169 if not os.path.isfile(source_grid):
7170 raise ValueError(f"Grid file not found: {source_grid}")
7171
7172 with open(source_grid, "r") as fin:
7173 line_iter = _iter_nonempty_noncomment_lines(fin)
7174 try:
7175 _, first_token = next(line_iter)
7176 except StopIteration:
7177 raise ValueError(f"Grid file '{source_grid}' is empty.")
7178
7179 if first_token != "PICGRID":
7180 raise ValueError(
7181 f"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
7182 )
7183 try:
7184 _, nblk_line = next(line_iter)
7185 except StopIteration:
7186 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
7187
7188 try:
7189 nblk = int(nblk_line)
7190 except ValueError:
7191 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
7192 if nblk <= 0:
7193 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
7194 if expected_nblk is not None and nblk != expected_nblk:
7195 raise ValueError(
7196 f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
7197 )
7198
7199 dims = []
7200 for bi in range(nblk):
7201 try:
7202 lineno, dim_line = next(line_iter)
7203 except StopIteration:
7204 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
7205 parts = dim_line.split()
7206 if len(parts) != 3:
7207 raise ValueError(
7208 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
7209 )
7210 try:
7211 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
7212 except ValueError:
7213 raise ValueError(
7214 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
7215 )
7216 if im <= 0 or jm <= 0 or km <= 0:
7217 raise ValueError(
7218 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
7219 )
7220 dims.append((im, jm, km))
7221
7222 total_nodes_expected = sum(im * jm * km for (im, jm, km) in dims)
7223 os.makedirs(os.path.dirname(dest_grid), exist_ok=True)
7224 with open(dest_grid, "w") as fout:
7225 fout.write("PICGRID\n")
7226 fout.write(f"{nblk}\n")
7227 for (im, jm, km) in dims:
7228 fout.write(f"{im} {jm} {km}\n")
7229
7230 total_nodes_seen = 0
7231 for lineno, coord_line in line_iter:
7232 parts = coord_line.split()
7233 if len(parts) != 3:
7234 raise ValueError(
7235 f"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
7236 )
7237 try:
7238 x = float(parts[0]) / L_ref
7239 y = float(parts[1]) / L_ref
7240 z = float(parts[2]) / L_ref
7241 except ValueError:
7242 raise ValueError(
7243 f"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
7244 )
7245 total_nodes_seen += 1
7246 if total_nodes_seen > total_nodes_expected:
7247 raise ValueError(
7248 f"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
7249 )
7250 fout.write(
7251 f"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
7252 f"{format_picgrid_coordinate(z)}\n"
7253 )
7254
7255 if total_nodes_seen != total_nodes_expected:
7256 raise ValueError(
7257 f"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
7258 )
7259
7260 return {"nblk": nblk, "dims": dims, "total_nodes": total_nodes_expected}
7261
7262def read_picgrid_header_dimensions(source_grid: str, expected_nblk: int = None) -> list:
7263 """!
7264 @brief Read only the canonical PICGRID header dimensions.
7265 @param[in] source_grid Input grid file path.
7266 @param[in] expected_nblk Optional expected block count.
7267 @return List of (IM, JM, KM) node-count tuples.
7268 @throws ValueError on malformed header.
7269 """
7270 if not os.path.isfile(source_grid):
7271 raise ValueError(f"Grid file not found: {source_grid}")
7272
7273 with open(source_grid, "r") as fin:
7274 line_iter = _iter_nonempty_noncomment_lines(fin)
7275 try:
7276 _, first_token = next(line_iter)
7277 except StopIteration:
7278 raise ValueError(f"Grid file '{source_grid}' is empty.")
7279 if first_token != "PICGRID":
7280 raise ValueError(f"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
7281
7282 try:
7283 _, nblk_line = next(line_iter)
7284 nblk = int(nblk_line)
7285 except StopIteration:
7286 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
7287 except ValueError:
7288 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
7289 if nblk <= 0:
7290 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
7291 if expected_nblk is not None and nblk != expected_nblk:
7292 raise ValueError(f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
7293
7294 dims = []
7295 for bi in range(nblk):
7296 try:
7297 lineno, dim_line = next(line_iter)
7298 except StopIteration:
7299 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
7300 parts = dim_line.split()
7301 if len(parts) != 3:
7302 raise ValueError(
7303 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
7304 )
7305 try:
7306 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
7307 except ValueError:
7308 raise ValueError(
7309 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
7310 )
7311 if im <= 0 or jm <= 0 or km <= 0:
7312 raise ValueError(
7313 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
7314 )
7315 dims.append((im, jm, km))
7316
7317 return dims
7318
7319def validate_and_nondimensionalize_picslice(source_slice: str, dest_slice: str, U_ref: float,
7320 expected_dims: tuple = None) -> dict:
7321 """!
7322 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
7323 @param[in] source_slice Input PICSLICE path.
7324 @param[in] dest_slice Output staged PICSLICE path.
7325 @param[in] U_ref Reference velocity for non-dimensionalization.
7326 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
7327 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
7328 @throws ValueError on malformed slice.
7329 """
7330 if U_ref == 0.0:
7331 raise ValueError("velocity_ref must be non-zero when processing PICSLICE speeds.")
7332 if not os.path.isfile(source_slice):
7333 raise ValueError(f"PICSLICE file not found: {source_slice}")
7334
7335 with open(source_slice, "r") as fin:
7336 line_iter = _iter_nonempty_noncomment_lines(fin)
7337 try:
7338 _, first_token = next(line_iter)
7339 except StopIteration:
7340 raise ValueError(f"PICSLICE file '{source_slice}' is empty.")
7341 if first_token != "PICSLICE":
7342 raise ValueError(f"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
7343
7344 try:
7345 _, frame_line = next(line_iter)
7346 frame_count = int(frame_line)
7347 except StopIteration:
7348 raise ValueError(f"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
7349 except ValueError:
7350 raise ValueError(f"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
7351 if frame_count != 1:
7352 raise ValueError(
7353 f"PICSLICE file '{source_slice}' has frame count {frame_count}; PICurv reads exactly one frame."
7354 )
7355
7356 try:
7357 lineno, dim_line = next(line_iter)
7358 except StopIteration:
7359 raise ValueError(f"PICSLICE file '{source_slice}' missing slice dimensions.")
7360 parts = dim_line.split()
7361 if len(parts) != 2:
7362 raise ValueError(
7363 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
7364 )
7365 try:
7366 n1, n2 = (int(parts[0]), int(parts[1]))
7367 except ValueError:
7368 raise ValueError(
7369 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
7370 )
7371 if n1 <= 0 or n2 <= 0:
7372 raise ValueError(f"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
7373 if expected_dims is not None and (n1, n2) != tuple(expected_dims):
7374 raise ValueError(
7375 f"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
7376 )
7377
7378 values = []
7379 for lineno, value_line in line_iter:
7380 parts = value_line.split()
7381 if len(parts) != 1:
7382 raise ValueError(
7383 f"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
7384 )
7385 try:
7386 value = float(parts[0])
7387 except ValueError:
7388 raise ValueError(f"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
7389 if not math.isfinite(value):
7390 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be finite.")
7391 if value < 0.0:
7392 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
7393 values.append(value)
7394
7395 expected_count = n1 * n2
7396 if len(values) != expected_count:
7397 raise ValueError(
7398 f"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
7399 )
7400
7401 os.makedirs(os.path.dirname(dest_slice), exist_ok=True)
7402 with open(dest_slice, "w") as fout:
7403 fout.write("PICSLICE\n")
7404 fout.write("1\n")
7405 fout.write(f"{n1} {n2}\n")
7406 for value in values:
7407 fout.write(f"{value / U_ref:.8e}\n")
7408
7409 return {
7410 "frame_count": frame_count,
7411 "dims": (n1, n2),
7412 "value_count": len(values),
7413 "min_speed": min(values) if values else 0.0,
7414 "max_speed": max(values) if values else 0.0,
7415 }
7416
7417def _face_artifact_token(face: str) -> str:
7418 """!
7419 @brief Convert a BC face token into a filesystem-friendly artifact token.
7420 @param[in] face Canonical face token such as -Zeta.
7421 @return Filesystem-friendly face token.
7422 """
7423 return face.replace("+", "pos").replace("-", "neg")
7424
7425def _resolve_run_artifact_path(run_dir: str, configured_path: str, default_path: str,
7426 default_to_config_dir: bool = False) -> str:
7427 """!
7428 @brief Resolve a run artifact path with run-dir-relative defaults.
7429 @param[in] run_dir Run/precompute directory root.
7430 @param[in] configured_path Optional user-provided artifact path.
7431 @param[in] default_path Default path relative to run_dir.
7432 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
7433 @return Absolute artifact path.
7434 """
7435 path = configured_path if configured_path else default_path
7436 if not isinstance(path, str) or not path.strip():
7437 raise ValueError("generated profile output_file must be a non-empty path when provided.")
7438 path = path.strip()
7439 if os.path.isabs(path):
7440 return os.path.abspath(path)
7441 if default_to_config_dir and os.path.dirname(path) == "":
7442 path = os.path.join("config", path)
7443 return os.path.abspath(os.path.join(run_dir, path))
7444
7445def _resolve_generator_script(configured_script: str, case_path: str, default_name: str) -> str:
7446 """!
7447 @brief Resolve an optional generator script override or repository default.
7448 @param[in] configured_script Optional absolute or case-relative script path.
7449 @param[in] case_path Current case.yml path used to anchor relative overrides.
7450 @param[in] default_name Repository generator filename under GENERATORS_PATH.
7451 @return Absolute generator script path.
7452 """
7453 if configured_script is None:
7454 return os.path.join(GENERATORS_PATH, default_name)
7455 if not isinstance(configured_script, str) or not configured_script.strip():
7456 raise ValueError(f"Generator script override for {default_name} must be a non-empty path.")
7457 script = configured_script.strip()
7458 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
7459 return _resolve_case_relative_path(script, case_dir)
7460
7461def _normalize_square_duct_poiseuille_params(params, field_name: str) -> dict:
7462 """!
7463 @brief Validate square-duct Poiseuille generator parameters.
7464 @param[in] params Generator params mapping.
7465 @param[in] field_name Human-readable YAML field name for diagnostics.
7466 @return Normalized params.
7467 """
7468 if params is None:
7469 params = {}
7470 if not isinstance(params, dict):
7471 raise ValueError(f"{field_name}.params must be a mapping when provided.")
7472 unknown = sorted(set(params.keys()) - {"bulk_velocity", "n_terms"})
7473 if unknown:
7474 raise ValueError(f"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
7475 bulk_velocity = _to_float(params.get("bulk_velocity", 1.0), f"{field_name}.params.bulk_velocity")
7476 if bulk_velocity <= 0.0:
7477 raise ValueError(f"{field_name}.params.bulk_velocity must be positive.")
7478 try:
7479 n_terms = int(params.get("n_terms", 101))
7480 except (TypeError, ValueError):
7481 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
7482 if n_terms <= 0 or n_terms % 2 == 0:
7483 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
7484 return {"bulk_velocity": bulk_velocity, "n_terms": n_terms}
7485
7486GENERATED_PROFILE_GENERATORS = {"square_duct_poiseuille"}
7487
7488def _normalize_field_slice_source(source, field_name: str) -> dict:
7489 """!
7490 @brief Validate a prescribed_flow field_slice source block.
7491 @param[in] source Source mapping from case.yml.
7492 @param[in] field_name Human-readable YAML path for diagnostics.
7493 @return Normalized source mapping.
7494 """
7495 allowed = {
7496 "type",
7497 "script",
7498 "field_file",
7499 "grid_file",
7500 "source_case",
7501 "velocity_scale",
7502 "source_block",
7503 "slice",
7504 }
7505 if "output_file" in source:
7506 raise ValueError(retired_destination_message(field_name, "output_file"))
7507 unknown = sorted(set(source.keys()) - allowed)
7508 if unknown:
7509 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
7510 field_file = source.get("field_file")
7511 grid_file = source.get("grid_file")
7512 if not isinstance(field_file, str) or not field_file.strip():
7513 raise ValueError(f"{field_name}.field_file must be a non-empty path.")
7514 if not isinstance(grid_file, str) or not grid_file.strip():
7515 raise ValueError(f"{field_name}.grid_file must be a non-empty path.")
7516 if source.get("source_case") is None and source.get("velocity_scale") is None:
7517 raise ValueError(f"{field_name} requires source_case or velocity_scale.")
7518
7519 normalized = {
7520 "type": "field_slice",
7521 "field_file": field_file.strip(),
7522 "grid_file": grid_file.strip(),
7523 "slice": _normalize_field_slice_selector(source.get("slice"), f"{field_name}.slice"),
7524 }
7525 if source.get("script") is not None:
7526 script = source.get("script")
7527 if not isinstance(script, str) or not script.strip():
7528 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
7529 normalized["script"] = script.strip()
7530 if source.get("source_case") is not None:
7531 source_case = source.get("source_case")
7532 if not isinstance(source_case, str) or not source_case.strip():
7533 raise ValueError(f"{field_name}.source_case must be a non-empty path when provided.")
7534 normalized["source_case"] = source_case.strip()
7535 if source.get("velocity_scale") is not None:
7536 velocity_scale = _to_float(source.get("velocity_scale"), f"{field_name}.velocity_scale")
7537 if velocity_scale <= 0.0:
7538 raise ValueError(f"{field_name}.velocity_scale must be positive.")
7539 normalized["velocity_scale"] = velocity_scale
7540 if source.get("source_block") is not None:
7541 try:
7542 source_block = int(source.get("source_block"))
7543 except (TypeError, ValueError):
7544 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
7545 if source_block < 0:
7546 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
7547 normalized["source_block"] = source_block
7548 return normalized
7549
7550def _normalize_field_slice_selector(slice_cfg, field_name: str) -> dict:
7551 """!
7552 @brief Validate the field_slice slice selector.
7553 @param[in] slice_cfg Slice selector mapping.
7554 @param[in] field_name Human-readable YAML path for diagnostics.
7555 @return Normalized selector mapping.
7556 """
7557 if not isinstance(slice_cfg, dict):
7558 raise ValueError(f"{field_name} must be a mapping.")
7559 orientation = str(slice_cfg.get("orientation", "opposite")).strip().lower()
7560 if orientation not in {"opposite", "same"}:
7561 raise ValueError(f"{field_name}.orientation must be 'opposite' or 'same'.")
7562 normal_tolerance = _to_float(slice_cfg.get("normal_tolerance", 0.99), f"{field_name}.normal_tolerance")
7563 if normal_tolerance <= 0.0 or normal_tolerance > 1.0:
7564 raise ValueError(f"{field_name}.normal_tolerance must be in the range (0, 1].")
7565
7566 if slice_cfg.get("face") is not None:
7567 unknown = sorted(set(slice_cfg.keys()) - {"face", "orientation", "normal_tolerance"})
7568 if unknown:
7569 raise ValueError(
7570 f"Unknown keys in {field_name}: {unknown}. "
7571 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
7572 )
7573 face = str(slice_cfg.get("face", "")).strip()
7574 if face.lower() not in BC_FACE_MAP:
7575 raise ValueError(f"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
7576 return {
7577 "face": BC_FACE_MAP[face.lower()],
7578 "orientation": orientation,
7579 "normal_tolerance": normal_tolerance,
7580 }
7581
7582 required = {"axis", "index", "normal"}
7583 missing = sorted(key for key in required if slice_cfg.get(key) is None)
7584 if missing:
7585 raise ValueError(f"{field_name} requires either face or axis/index/normal; missing {missing}.")
7586 unknown = sorted(set(slice_cfg.keys()) - {"axis", "index", "normal", "orientation", "normal_tolerance"})
7587 if unknown:
7588 raise ValueError(
7589 f"Unknown keys in {field_name}: {unknown}. "
7590 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
7591 )
7592 axis = str(slice_cfg.get("axis", "")).strip()
7593 axis_map = {"xi": "Xi", "eta": "Eta", "zeta": "Zeta"}
7594 if axis.lower() not in axis_map:
7595 raise ValueError(f"{field_name}.axis must be one of Xi, Eta, Zeta.")
7596 normal = str(slice_cfg.get("normal", "")).strip()
7597 if normal.lower() not in BC_FACE_MAP:
7598 raise ValueError(f"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
7599 normal = BC_FACE_MAP[normal.lower()]
7600 if normal[1:].lower() != axis.lower():
7601 raise ValueError(f"{field_name}.normal must use the same axis as {field_name}.axis.")
7602 try:
7603 index = int(slice_cfg.get("index"))
7604 except (TypeError, ValueError):
7605 raise ValueError(f"{field_name}.index must be an integer.")
7606 if index < 0:
7607 raise ValueError(f"{field_name}.index must be non-negative.")
7608 return {
7609 "axis": axis_map[axis.lower()],
7610 "index": index,
7611 "normal": normal,
7612 "orientation": orientation,
7613 "normal_tolerance": normal_tolerance,
7614 }
7615
7616def generate_square_duct_poiseuille_picslice(output_path: str, dims: tuple, params: dict,
7617 target_grid: str = None, target_block: int = 0,
7618 target_face: str = None, script: str = None,
7619 case_path: str = None) -> dict:
7620 """!
7621 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
7622 @param[in] output_path Path to write.
7623 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
7624 @param[in] params Normalized generator params.
7625 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
7626 @param[in] target_block Target block index when `target_grid` is provided.
7627 @param[in] target_face Target inlet face when `target_grid` is provided.
7628 @param[in] script Optional profile.gen-compatible script override.
7629 @param[in] case_path Current case.yml path used to anchor relative script overrides.
7630 @return Summary dictionary.
7631 """
7632 n1, n2 = tuple(dims)
7633 profilegen_script = _resolve_generator_script(script, case_path, "profile.gen")
7634 if not os.path.isfile(profilegen_script):
7635 raise ValueError(f"profile.gen script not found: {profilegen_script}")
7636 cmd = [
7637 sys.executable,
7638 profilegen_script,
7639 "square_duct_poiseuille",
7640 "--output",
7641 output_path,
7642 "--dims",
7643 str(n1),
7644 str(n2),
7645 "--bulk-velocity",
7646 str(float(params["bulk_velocity"])),
7647 "--n-terms",
7648 str(int(params["n_terms"])),
7649 ]
7650 if target_grid:
7651 cmd.extend([
7652 "--target-grid",
7653 target_grid,
7654 "--target-block",
7655 str(int(target_block)),
7656 f"--target-face={target_face}",
7657 ])
7658 result = subprocess.run(cmd, text=True, capture_output=True)
7659 if result.returncode != 0:
7660 details = (result.stderr or result.stdout or "").strip()
7661 raise ValueError(f"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
7662 try:
7663 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
7664 except (IndexError, json.JSONDecodeError) as exc:
7665 raise ValueError(f"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}") from exc
7666 summary["dims"] = tuple(summary["dims"])
7667 return summary
7668
7669def generate_field_slice_picslice(output_path: str, expected_dims: tuple, source: dict,
7670 target_grid: str, target_face: str, target_block: int,
7671 case_path: str) -> dict:
7672 """!
7673 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
7674 @param[in] output_path Path to write.
7675 @param[in] expected_dims Expected PICSLICE dimensions.
7676 @param[in] source Normalized field_slice source mapping.
7677 @param[in] target_grid Target canonical PICGRID path.
7678 @param[in] target_face Target inlet face token.
7679 @param[in] target_block Target block index.
7680 @param[in] case_path Path to current case.yml for relative source resolution.
7681 @return Summary dictionary from profile.gen.
7682 """
7683 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
7684 field_file = _resolve_case_relative_path(source["field_file"], case_dir)
7685 source_grid = _resolve_case_relative_path(source["grid_file"], case_dir)
7686 velocity_scale = _resolve_field_slice_velocity_scale(source, case_dir)
7687 profilegen_script = _resolve_generator_script(source.get("script"), case_path, "profile.gen")
7688 if not os.path.isfile(profilegen_script):
7689 raise ValueError(f"profile.gen script not found: {profilegen_script}")
7690 n1, n2 = tuple(expected_dims)
7691 slice_cfg = source["slice"]
7692 cmd = [
7693 sys.executable,
7694 profilegen_script,
7695 "field-slice",
7696 "--output",
7697 output_path,
7698 "--field-file",
7699 field_file,
7700 "--source-grid",
7701 source_grid,
7702 "--target-grid",
7703 target_grid,
7704 "--source-block",
7705 str(int(source.get("source_block", 0))),
7706 "--target-block",
7707 str(int(target_block)),
7708 f"--target-face={target_face}",
7709 "--orientation",
7710 slice_cfg["orientation"],
7711 "--normal-tolerance",
7712 str(float(slice_cfg["normal_tolerance"])),
7713 "--velocity-scale",
7714 str(float(velocity_scale)),
7715 "--expected-dims",
7716 str(n1),
7717 str(n2),
7719 ]
7720 if "face" in slice_cfg:
7721 cmd.append(f"--slice-face={slice_cfg['face']}")
7722 else:
7723 cmd.extend([
7724 "--slice-axis",
7725 slice_cfg["axis"],
7726 "--slice-index",
7727 str(int(slice_cfg["index"])),
7728 f"--slice-normal={slice_cfg['normal']}",
7729 ])
7730 result = subprocess.run(cmd, text=True, capture_output=True)
7731 if result.returncode != 0:
7732 details = (result.stderr or result.stdout or "").strip()
7733 raise ValueError(f"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
7734 try:
7735 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
7736 except (IndexError, json.JSONDecodeError) as exc:
7737 raise ValueError(f"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}") from exc
7738 summary["dims"] = tuple(summary["dims"])
7739 return summary
7740
7741def _resolve_case_relative_path(path_value: str, case_dir: str) -> str:
7742 """!
7743 @brief Resolve a path relative to the current case directory.
7744 @param[in] path_value Path from case.yml.
7745 @param[in] case_dir Current case directory.
7746 @return Absolute path.
7747 """
7748 if not isinstance(path_value, str) or not path_value.strip():
7749 raise ValueError("path value must be a non-empty string.")
7750 workspace_root = find_workspace_root(case_dir)
7751 if workspace_root:
7752 if os.path.isabs(path_value):
7753 raise ValueError(
7754 f"absolute path {path_value!r} is not allowed in workspace case configuration; "
7755 "use 'picurv inputs import --mode reference' for an explicit external reference."
7756 )
7757 resolved = os.path.abspath(os.path.join(workspace_root, path_value))
7758 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
7759 raise ValueError(f"path {path_value!r} escapes the workspace.")
7760 if resolved.endswith(".reference.yml") and os.path.isfile(resolved):
7761 pointer = read_yaml_file(resolved)
7762 external = pointer.get("picurv_external_reference") if isinstance(pointer, dict) else None
7763 if not isinstance(external, str) or not os.path.isabs(external):
7764 raise ValueError(f"Invalid external-reference descriptor: {resolved}")
7765 if not os.path.isfile(external):
7766 raise ValueError(f"Registered external input is unavailable: {external}")
7767 return external
7768 return resolved
7769 if os.path.isabs(path_value):
7770 return os.path.abspath(path_value)
7771 return os.path.abspath(os.path.join(case_dir, path_value))
7772
7773def _resolve_field_slice_velocity_scale(source: dict, case_dir: str) -> float:
7774 """!
7775 @brief Resolve field_slice dimensional velocity scale.
7776 @param[in] source Normalized field_slice source mapping.
7777 @param[in] case_dir Current case directory.
7778 @return Positive velocity scale.
7779 """
7780 if source.get("velocity_scale") is not None:
7781 return float(source["velocity_scale"])
7782 source_case = _resolve_case_relative_path(source["source_case"], case_dir)
7783 source_case_cfg = read_yaml_file(source_case)
7784 try:
7785 velocity_scale = _to_float(
7786 source_case_cfg.get("properties", {}).get("scaling", {}).get("velocity_ref"),
7787 "source_case.properties.scaling.velocity_ref",
7788 )
7789 except AttributeError as exc:
7790 raise ValueError("source_case must contain properties.scaling.velocity_ref.") from exc
7791 if velocity_scale <= 0.0:
7792 raise ValueError("source_case.properties.scaling.velocity_ref must be positive.")
7793 return velocity_scale
7794
7795def resolve_target_grid_for_field_slice(case_cfg: dict, case_path: str, run_dir: str) -> str:
7796 """!
7797 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
7798 @param[in] case_cfg Parsed current case config.
7799 @param[in] case_path Current case.yml path.
7800 @param[in] run_dir Current run/precompute directory.
7801 @return Absolute target PICGRID path.
7802 """
7803 grid_cfg = case_cfg.get("grid", {}) or {}
7804 grid_mode = grid_cfg.get("mode")
7805 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
7806 if grid_mode == "file":
7807 source_grid = grid_cfg.get("source_file")
7808 if not isinstance(source_grid, str) or not source_grid.strip():
7809 raise ValueError("grid.source_file is required for field_slice target-grid normals.")
7810 source_grid = _resolve_case_relative_path(source_grid, case_dir)
7811 return source_grid
7812 if grid_mode == "grid_gen":
7813 candidate = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
7814 if os.path.isfile(candidate):
7815 return candidate
7816 staged = os.path.join(run_dir, "inputs", "grid", "grid.run")
7817 if os.path.isfile(staged):
7818 return staged
7819 raise ValueError("field_slice requires the generated target PICGRID to exist before profile extraction.")
7820 raise ValueError(
7821 f"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
7822 )
7823
7824def resolve_target_grid_for_generated_profile(case_cfg: dict, case_path: str, run_dir: str) -> str:
7825 """!
7826 @brief Resolve the target canonical PICGRID for generated profile sampling.
7827 @details The simulator builds a programmatic_c grid itself, so no grid file exists
7828 here. The profile is sampled instead on the bridge PICGRID that a Python
7829 initial condition already uses, written with the solver's own node formula.
7830 Without a grid the generator could only sample uniform logical points and
7831 normalize to the continuous-area mean, which delivered about 2/n too much
7832 flux on n cells across (programmatic-inlet-flux-2026-09-18).
7833 @param[in] case_cfg Parsed current case config.
7834 @param[in] case_path Current case.yml path.
7835 @param[in] run_dir Current run/precompute directory.
7836 @return Absolute target PICGRID path.
7837 """
7838 grid_cfg = case_cfg.get("grid", {}) or {}
7839 if grid_cfg.get("mode") == "programmatic_c":
7840 bridge = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.run"))
7841 if not os.path.isfile(bridge):
7843 grid_cfg.get("programmatic_settings", {}), bridge,
7844 input_reference_scale(("case", "grid", "programmatic_settings", "xMins"),
7845 resolve_reference_scales(case_cfg)),
7846 )
7847 return bridge
7848 return resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
7849
7850def write_profile_info(config_dir: str, summaries: list) -> str:
7851 """!
7852 @brief Write a profile.info summary for generated inlet profiles.
7853 @param[in] config_dir Run/precompute config directory.
7854 @param[in] summaries Generated profile summaries.
7855 @return Path to profile.info.
7856 """
7857 info_path = os.path.join(config_dir, "profile.info")
7858 os.makedirs(config_dir, exist_ok=True)
7859 with open(info_path, "w") as fout:
7860 fout.write("# PICurv generated profile summary\n")
7861 fout.write(f"profile_count = {len(summaries)}\n\n")
7862 for idx, summary in enumerate(summaries):
7863 dims = summary.get("dims", (0, 0))
7864 fout.write(f"[profile_{idx}]\n")
7865 fout.write(f"generator = {summary.get('generator')}\n")
7866 fout.write(f"block = {summary.get('block')}\n")
7867 fout.write(f"face = {summary.get('face')}\n")
7868 fout.write(f"dimensions = {dims[0]} {dims[1]}\n")
7869 if summary.get("bulk_velocity") is not None:
7870 fout.write(f"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
7871 if summary.get("n_terms") is not None:
7872 fout.write(f"n_terms = {summary.get('n_terms')}\n")
7873 fout.write(f"mean_speed = {summary.get('mean_speed'):.16e}\n")
7874 if "area_mean_speed" in summary:
7875 fout.write(f"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
7876 if "discrete_mean_speed" in summary:
7877 fout.write(f"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
7878 fout.write(f"min_speed = {summary.get('min_speed'):.16e}\n")
7879 fout.write(f"max_speed = {summary.get('max_speed'):.16e}\n")
7880 if summary.get("umax_over_ubulk") is not None:
7881 fout.write(f"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
7882 for key in (
7883 "normalization",
7884 "sampling",
7885 "area_weighted_mean_before_normalization",
7886 "area_weighted_mean_after_normalization",
7887 "total_inlet_area",
7888 "face_area_min",
7889 "face_area_max",
7890 "source_field",
7891 "source_grid",
7892 "target_grid",
7893 "source_block",
7894 "target_block",
7895 "target_face",
7896 "source_slice",
7897 "orientation",
7898 "normal_tolerance",
7899 "normal_dot",
7900 "velocity_scale",
7901 ):
7902 if key in summary:
7903 fout.write(f"{key} = {summary.get(key)}\n")
7904 fout.write(f"output_file = {summary.get('path')}\n\n")
7905 return info_path
7906
7907#: Generator flags whose values are drawn from a closed choice set. Checking them where
7908#: the case is validated turns a mid-run subprocess failure into a startup error.
7909GRID_GENERATOR_CHOICE_FLAGS = {
7910 "--cross-section": GRID_CROSS_SECTION_KINDS,
7911}
7912
7913#: Generator flags taking an ordered `kind:key=value` list whose kinds are closed.
7914GRID_GENERATOR_SPEC_FLAGS = {
7915 "--wall-j-lo": GRID_WALL_SEGMENT_KINDS,
7916 "--wall-j-hi": GRID_WALL_SEGMENT_KINDS,
7917 "--wall-j-lo-span": GRID_WALL_SEGMENT_KINDS,
7918 "--wall-j-hi-span": GRID_WALL_SEGMENT_KINDS,
7919 "--cross-section-scale": GRID_WALL_SEGMENT_KINDS,
7920 "--path": GRID_PATH_SEGMENT_KINDS,
7921 "--transforms": GRID_TRANSFORM_KINDS,
7922}
7923
7924
7925def validate_grid_generator_cli_args(cli_args, case_path: str) -> list:
7926 """!
7927 @brief Check closed-choice values inside the generator's opaque token list.
7928 @details `cli_args` is passed through to grid.gen untouched, so a misspelled geometry
7929 selector there is only discovered when the subprocess exits nonzero partway
7930 into a run. The generator remains the authority; this reports the same set
7931 earlier, and stays silent on anything it does not recognize.
7932 @param[in] cli_args Raw token list from grid.generator.cli_args.
7933 @param[in] case_path Case file path for diagnostics.
7934 @return List of error strings.
7935 """
7936 if not isinstance(cli_args, list):
7937 return []
7938 tokens = [str(token) for token in cli_args]
7939 errors = []
7940 for index, token in enumerate(tokens):
7941 values = []
7942 for follower in tokens[index + 1:]:
7943 if follower.startswith("--"):
7944 break
7945 values.append(follower)
7946 if token in GRID_GENERATOR_CHOICE_FLAGS:
7947 allowed = GRID_GENERATOR_CHOICE_FLAGS[token]
7948 for value in values[:1]:
7949 if value not in allowed:
7950 errors.append(
7951 f" {case_path}: grid.generator.cli_args {token} must be one of "
7952 f"{list(allowed)} (got '{value}')."
7953 )
7954 elif token in GRID_GENERATOR_SPEC_FLAGS:
7955 allowed = GRID_GENERATOR_SPEC_FLAGS[token]
7956 for value in values:
7957 kind = value.split(":", 1)[0]
7958 if kind not in allowed:
7959 errors.append(
7960 f" {case_path}: grid.generator.cli_args {token} entry '{value}' "
7961 f"names '{kind}', which is not one of {list(allowed)}."
7962 )
7963 return errors
7964
7965
7966def run_grid_generator(case_path: str, run_dir: str, grid_cfg: dict,
7967 case_cfg: dict = None) -> str:
7968 """!
7969 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
7970 @param[in] case_path Path to case.yml (used for relative path resolution).
7971 @param[in] run_dir Run directory path.
7972 @param[in] grid_cfg The grid config section from case.yml.
7973 @param[in] case_cfg Parsed case configuration, when available. Its reference scales
7974 are handed to the generator so the quality report can carry
7975 solver and wall units. Reporting only: the generator never scales
7976 coordinates, which validate_and_nondimensionalize_picgrid does
7977 once for every grid regardless of origin.
7978 @return Absolute path to generated dimensional PICGRID file.
7979 @throws ValueError on invalid config or generator failure.
7980 """
7981 generator = grid_cfg.get("generator", {})
7982 if not isinstance(generator, dict):
7983 raise ValueError("grid.generator must be a mapping when grid.mode is 'grid_gen'.")
7984
7985 case_dir = os.path.dirname(os.path.abspath(case_path))
7986 gridgen_script = generator.get("script", os.path.join(GENERATORS_PATH, "grid.gen"))
7987 if generator.get("script"):
7988 gridgen_script = _resolve_case_relative_path(gridgen_script, case_dir)
7989 else:
7990 gridgen_script = os.path.abspath(gridgen_script)
7991 if not os.path.isfile(gridgen_script):
7992 raise ValueError(f"grid.gen script not found: {gridgen_script}")
7993
7994 config_file = generator.get("config_file")
7995 if not config_file:
7996 raise ValueError("grid.generator.config_file is required when grid.mode is 'grid_gen'.")
7997 config_file = _resolve_case_relative_path(config_file, case_dir)
7998 if not os.path.isfile(config_file):
7999 raise ValueError(f"grid.generator.config_file not found: {config_file}")
8000
8001 output_file = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
8002 os.makedirs(os.path.dirname(output_file), exist_ok=True)
8003
8004 grid_type = generator.get("grid_type")
8005 cli_args = generator.get("cli_args", [])
8006 if cli_args is None:
8007 cli_args = []
8008 if not isinstance(cli_args, list):
8009 raise ValueError("grid.generator.cli_args must be a list of CLI tokens.")
8010
8011 cmd = [sys.executable, gridgen_script, "-c", config_file]
8012 if grid_type:
8013 cmd.append(str(grid_type))
8014 cmd.extend([str(token) for token in cli_args])
8015 cmd.extend(["--output", output_file])
8016
8017 # Destinations are PICurv's, not the user's. Inspection material is produced
8018 # unconditionally so a published asset is always something the user can look at,
8019 # rather than only when a configuration file happened to name an output path.
8020 vts_file = os.path.abspath(
8021 os.path.join(run_dir, "output", "visualization", "precompute", "grid.vts")
8022 )
8023 os.makedirs(os.path.dirname(vts_file), exist_ok=True)
8024 cmd.extend(["--vts", vts_file])
8025
8026 stats_file = os.path.abspath(
8027 os.path.join(run_dir, "output", "analysis", "metrics", "grid.info")
8028 )
8029 os.makedirs(os.path.dirname(stats_file), exist_ok=True)
8030 cmd.extend(["--stats-file", stats_file])
8031
8032 # The case already knows these; the generator would otherwise report a grid in metres
8033 # with no way to say what it becomes downstream. Resolution is best-effort: a case
8034 # that omits them still gets its grid, just without the derived-unit sections.
8035 if case_cfg:
8036 try:
8037 scaling = resolve_fluid_scaling(case_cfg)
8038 except (KeyError, ValueError, TypeError):
8039 scaling = None
8040 if scaling:
8041 cmd.extend(["--length-ref", repr(scaling["length_ref"]),
8042 "--velocity-ref", repr(scaling["velocity_ref"]),
8043 "--nu", repr(scaling["physical_kinematic_viscosity"])])
8044
8045 print(f"[INFO] Grid generator command: {' '.join(cmd)}")
8046 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
8047 if result.returncode != 0:
8048 stderr = (result.stderr or "").strip()
8049 stdout = (result.stdout or "").strip()
8050 details = stderr if stderr else stdout
8051 raise ValueError(
8052 f"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
8053 )
8054 if result.stdout:
8055 print(result.stdout.strip())
8056 if result.stderr:
8057 print(result.stderr.strip())
8058
8059 if not os.path.isfile(output_file):
8060 raise ValueError(f"grid.gen did not produce expected output file: {output_file}")
8061
8062 return output_file
8063
8064
8065BC_FACE_MAP = {
8066 "-xi": "-Xi",
8067 "+xi": "+Xi",
8068 "-eta": "-Eta",
8069 "+eta": "+Eta",
8070 "-zeta": "-Zeta",
8071 "+zeta": "+Zeta",
8072}
8073
8074# Canonical BC selector maps. Add new values only with matching C parser/factory support.
8075BC_TYPE_MAP = {
8076 "wall": "WALL",
8077 "inlet": "INLET",
8078 "outlet": "OUTLET",
8079 "periodic": "PERIODIC",
8080}
8081
8082# What a particle crossing a boundary face becomes: mirrored back about the face plane,
8083# removed as having left the domain, or (periodic faces only) re-entered through the paired
8084# face. Each handler states its default; a BC entry may override it with `particle_policy`.
8085PARTICLE_BOUNDARY_POLICIES = ("reflect", "remove", "wrap")
8086
8087BC_HANDLER_SPECS = {
8088 # Only handlers that are implemented end-to-end in current C path are allowed.
8089 "noslip": {
8090 "types": {"WALL"},
8091 "particle_policy": "reflect",
8092 "required_params": set(),
8093 "optional_params": set(),
8094 },
8095 # Impenetrable and shear-free; also the condition at a plane of symmetry.
8096 "free_slip": {
8097 "types": {"WALL"},
8098 "particle_policy": "reflect",
8099 "required_params": set(),
8100 "optional_params": set(),
8101 },
8102 "constant_velocity": {
8103 "types": {"INLET"},
8104 "particle_policy": "remove",
8105 "required_params": {"vx", "vy", "vz"},
8106 "optional_params": set(),
8107 },
8108 "conservation": {
8109 "types": {"OUTLET"},
8110 "particle_policy": "remove",
8111 "required_params": set(),
8112 "optional_params": set(),
8113 },
8114 "parabolic": {
8115 "types": {"INLET"},
8116 "particle_policy": "remove",
8117 "required_params": {"v_max"},
8118 "optional_params": set(),
8119 },
8120 "prescribed_flow": {
8121 "types": {"INLET"},
8122 "particle_policy": "remove",
8123 "required_params": {"source"},
8124 "optional_params": set(),
8125 },
8126 "geometric": {
8127 "types": {"PERIODIC"},
8128 "particle_policy": "wrap",
8129 "required_params": set(),
8130 "optional_params": set(),
8131 },
8132 "constant_flux": {
8133 "types": {"PERIODIC"},
8134 "particle_policy": "wrap",
8135 "required_params": {"target_flux"},
8136 "optional_params": {"enforce_seam_flux", "apply_trim"},
8137 },
8138 # Derives its own target from the initial condition, so it takes no target_flux.
8139 "initial_flux": {
8140 "types": {"PERIODIC"},
8141 "particle_policy": "wrap",
8142 "required_params": set(),
8143 "optional_params": {"enforce_seam_flux", "apply_trim"},
8144 },
8145}
8146
8147_NUMERIC_BC_PARAMS = {"vx", "vy", "vz", "v_max", "target_flux"}
8148_BOOL_BC_PARAMS = {"enforce_seam_flux", "apply_trim"}
8149
8150# Deprecated BC param spellings -> canonical name. `apply_trim` said that
8151# something was trimmed but not what or why; `enforce_seam_flux` names the
8152# quantity it holds on target. Both are accepted; bcs.run carries the canonical
8153# name, and the C side also falls back to the old spelling for archived files.
8154_DEPRECATED_BC_PARAM_ALIASES = {"apply_trim": "enforce_seam_flux"}
8155
8156def _normalize_prescribed_flow_source(source, field_name: str) -> dict:
8157 """!
8158 @brief Validate the structured source block for prescribed_flow BCs.
8159 @param[in] source Source mapping from case.yml.
8160 @param[in] field_name Human-readable YAML path for diagnostics.
8161 @return Normalized source mapping.
8162 @throws ValueError on invalid source contract.
8163 """
8164 if not isinstance(source, dict):
8165 raise ValueError(f"{field_name} must be a mapping with type: file, generated, or field_slice.")
8166 source_type = str(source.get("type", "")).strip().lower()
8167 if source_type == "file":
8168 path = source.get("path")
8169 if not isinstance(path, str) or not path.strip():
8170 raise ValueError(f"{field_name}.path must be a non-empty file path.")
8171 unknown = sorted(set(source.keys()) - {"type", "path"})
8172 if unknown:
8173 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
8174 return {"type": "file", "path": path.strip()}
8175
8176 if source_type == "generated":
8177 generator = str(source.get("generator", "")).strip().lower()
8178 if generator not in GENERATED_PROFILE_GENERATORS:
8179 raise ValueError(
8180 f"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
8181 f"(got '{source.get('generator')}')."
8182 )
8183 if "output_file" in source:
8184 raise ValueError(retired_destination_message(field_name, "output_file"))
8185 unknown = sorted(set(source.keys()) - {"type", "generator", "script", "params"})
8186 if unknown:
8187 raise ValueError(
8188 f"Unknown keys in {field_name}: {unknown}. "
8189 "Allowed: ['generator', 'params', 'script', 'type']."
8190 )
8191 normalized = {
8192 "type": "generated",
8193 "generator": generator,
8194 "params": _normalize_square_duct_poiseuille_params(source.get("params", {}), field_name),
8195 }
8196 script = source.get("script")
8197 if script is not None:
8198 if not isinstance(script, str) or not script.strip():
8199 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
8200 normalized["script"] = script.strip()
8201 return normalized
8202
8203 if source_type == "field_slice":
8204 return _normalize_field_slice_source(source, field_name)
8205
8206 raise ValueError(f"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
8207
8208def _bc_profile_expected_dims(face: str, block_dims: tuple) -> tuple:
8209 """!
8210 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
8211 @param[in] face Canonical BC face token.
8212 @param[in] block_dims (IM, JM, KM) node counts.
8213 @return (n1, n2) dimensions in profile storage order.
8214 """
8215 im, jm, km = block_dims
8216 if min(im, jm, km) < 2:
8217 raise ValueError(
8218 f"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
8219 )
8220 if face in {"-Xi", "+Xi"}:
8221 return (km - 1, jm - 1)
8222 if face in {"-Eta", "+Eta"}:
8223 return (km - 1, im - 1)
8224 if face in {"-Zeta", "+Zeta"}:
8225 return (jm - 1, im - 1)
8226 raise ValueError(f"Unsupported face '{face}' for prescribed_flow profile dimensions.")
8227
8228def resolve_grid_block_dimensions_for_profiles(case_cfg: dict, case_path: str, run_dir: str = None) -> list:
8229 """!
8230 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
8231 @param[in] case_cfg Parsed case.yml configuration.
8232 @param[in] case_path Path to case.yml for relative path resolution.
8233 @param[in] run_dir Current run directory, used for optional generated grid outputs.
8234 @return List of (IM, JM, KM) node-count tuples.
8235 @throws ValueError when dimensions cannot be resolved.
8236 """
8237 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
8238 grid_cfg = case_cfg.get("grid", {})
8239 grid_mode = grid_cfg.get("mode")
8240 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
8241
8242 if grid_mode == "programmatic_c":
8243 settings = grid_cfg.get("programmatic_settings", {})
8244 dims_by_axis = []
8245 for key in ("im", "jm", "km"):
8246 raw = settings.get(key)
8247 if raw is None:
8248 raise ValueError(f"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
8249 if isinstance(raw, list):
8250 if len(raw) != num_blocks:
8251 raise ValueError(
8252 f"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
8253 )
8254 values = raw
8255 else:
8256 values = [raw] * num_blocks
8257 try:
8258 values = [int(v) + 1 for v in values]
8259 except (TypeError, ValueError):
8260 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
8261 if any(v <= 1 for v in values):
8262 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
8263 dims_by_axis.append(values)
8264 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
8265
8266 if grid_mode == "file":
8267 source_grid = grid_cfg.get("source_file")
8268 if not isinstance(source_grid, str) or not source_grid.strip():
8269 raise ValueError("grid.source_file is required for file-grid prescribed_flow profile validation.")
8270 source_grid = _resolve_case_relative_path(source_grid, case_dir)
8271 return read_picgrid_header_dimensions(source_grid, expected_nblk=num_blocks)
8272
8273 if grid_mode == "grid_gen":
8274 candidates = []
8275 if run_dir:
8276 candidates.append(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
8277 candidates.append(os.path.join(run_dir, "inputs", "grid", "grid.run"))
8278 for candidate in candidates:
8279 if os.path.isfile(candidate):
8280 return read_picgrid_header_dimensions(candidate, expected_nblk=num_blocks)
8281 raise ValueError(
8282 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
8283 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
8284 )
8285
8286 raise ValueError(f"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
8287
8288def materialize_generated_prescribed_flow_profiles(run_dir: str, case_cfg: dict, case_path: str,
8289 profile_grid_dims: list = None) -> list:
8290 """!
8291 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
8292 @param[in] run_dir Run/precompute directory root.
8293 @param[in] case_cfg Parsed case.yml.
8294 @param[in] case_path Path to case.yml for relative grid/source resolution.
8295 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
8296 @return List of generated profile summaries.
8297 """
8298 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
8299 if not any(
8300 bc.get("handler") == "prescribed_flow"
8301 and ((bc.get("params") or {}).get("source") or {}).get("type") in PRESCRIBED_FLOW_SOURCE_TYPES[1:]
8302 for block in prepared_blocks for bc in block
8303 ):
8304 return []
8305 if profile_grid_dims is None:
8306 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
8307
8308 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
8309 target_grid = None
8310 generated_target_grid = None
8311 summaries = []
8312 for block_idx, block in enumerate(prepared_blocks):
8313 for bc in block:
8314 if bc.get("handler") != "prescribed_flow":
8315 continue
8316 source = (bc.get("params") or {}).get("source", {})
8317 if source.get("type") not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
8318 continue
8319 face = bc["face"]
8320 dims = _bc_profile_expected_dims(face, profile_grid_dims[block_idx])
8321 face_token = _face_artifact_token(face)
8322 suffix = "generated" if source.get("type") == "generated" else "sliced"
8323 output_path = os.path.join(
8324 profile_dir,
8325 f"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice",
8326 )
8327 if source.get("type") == "generated" and source["generator"] == "square_duct_poiseuille":
8328 if generated_target_grid is None:
8329 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
8331 output_path,
8332 dims,
8333 source["params"],
8334 target_grid=generated_target_grid,
8335 target_block=block_idx,
8336 target_face=face,
8337 script=source.get("script"),
8338 case_path=case_path,
8339 )
8340 elif source.get("type") == "generated":
8341 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
8342 else:
8343 if target_grid is None:
8344 target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
8346 output_path,
8347 dims,
8348 source,
8349 target_grid,
8350 face,
8351 block_idx,
8352 case_path,
8353 )
8354 summary.update({"block": block_idx, "face": face})
8355 summaries.append(summary)
8356 print(
8357 f"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
8358 f"{os.path.relpath(output_path)} dims={summary['dims']}"
8359 )
8360
8361 if summaries:
8362 info_path = write_profile_info(profile_dir, summaries)
8363 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
8364 return summaries
8365
8366def _to_float(value, field_name: str) -> float:
8367 """!
8368 @brief Convert a YAML scalar to float with a clear error message.
8369 @param[in] value Argument passed to `_to_float()`.
8370 @param[in] field_name Argument passed to `_to_float()`.
8371 @return Value returned by `_to_float()`.
8372 """
8373 try:
8374 return float(value)
8375 except (TypeError, ValueError):
8376 raise ValueError(f"'{field_name}' must be numeric (got {value!r}).")
8377
8378
8379def _to_finite_float(value, field_name: str) -> float:
8380 """!
8381 @brief Convert a non-boolean YAML scalar to a finite float.
8382 @param[in] value Raw YAML scalar.
8383 @param[in] field_name User-facing configuration path.
8384 @return Finite floating-point value.
8385 """
8386 if isinstance(value, bool):
8387 raise ValueError(f"'{field_name}' must be numeric, not boolean.")
8388 parsed = _to_float(value, field_name)
8389 if not math.isfinite(parsed):
8390 raise ValueError(f"'{field_name}' must be finite (got {value!r}).")
8391 return parsed
8392
8393def _to_bool(value, field_name: str) -> bool:
8394 """!
8395 @brief Convert a YAML scalar/string to bool with a clear error message.
8396 @param[in] value Argument passed to `_to_bool()`.
8397 @param[in] field_name Argument passed to `_to_bool()`.
8398 @return Value returned by `_to_bool()`.
8399 """
8400 if isinstance(value, bool):
8401 return value
8402 if isinstance(value, str):
8403 raw = value.strip().lower()
8404 if raw in {"true", "1", "yes"}:
8405 return True
8406 if raw in {"false", "0", "no"}:
8407 return False
8408 raise ValueError(f"'{field_name}' must be boolean (got {value!r}).")
8409
8410def normalize_boundary_conditions_layout(all_blocks_bcs, num_blocks: int):
8411 """!
8412 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
8413 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
8414 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
8415 @return Value returned by `normalize_boundary_conditions_layout()`.
8416 """
8417 if not all_blocks_bcs:
8418 raise ValueError("The 'boundary_conditions' section in case.yml is empty.")
8419
8420 is_simple_list = isinstance(all_blocks_bcs[0], dict)
8421 if num_blocks == 1 and is_simple_list:
8422 all_blocks_bcs = [all_blocks_bcs]
8423 elif is_simple_list and num_blocks > 1:
8424 raise ValueError(
8425 f"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
8426 "Use a list-of-lists, one inner list per block."
8427 )
8428
8429 if len(all_blocks_bcs) != num_blocks:
8430 raise ValueError(
8431 f"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
8432 )
8433 return all_blocks_bcs
8434
8435def _les_periodic_axes(case_cfg: dict) -> set:
8436 """!
8437 @brief Reports which logical axes a case declares periodic on both faces.
8438 @param[in] case_cfg Parsed case.yml mapping.
8439 @return Set of axis letters drawn from {'i', 'j', 'k'}.
8440 """
8441 axis_faces = {"i": ("-Xi", "+Xi"), "j": ("-Eta", "+Eta"), "k": ("-Zeta", "+Zeta")}
8442 declared = {}
8443 entries = case_cfg.get("boundary_conditions") or []
8444 if entries and isinstance(entries[0], list):
8445 entries = entries[0]
8446 for entry in entries:
8447 if isinstance(entry, dict):
8448 declared[str(entry.get("face"))] = str(entry.get("type", "")).upper()
8449 return {
8450 axis for axis, faces in axis_faces.items()
8451 if all(declared.get(face) == "PERIODIC" for face in faces)
8452 }
8453
8454
8455#: Refused whenever a case carries a RANS block. The k-omega transport update was never
8456#: written and its fields were never allocated, so the closure was removed on 2026-09-22
8457#: and the subsystem returned to `planned`; src/guide.md records the hooks that went with
8458#: it. The message names the status rather than leaving a silently ignored block.
8459RANS_PLANNED_REFUSAL = (
8460 "models.physics.turbulence.rans is not accepted: RANS closures are planned, not "
8461 "implemented. No transport equation was ever solved for them, and the selector and "
8462 "its dead hooks were removed. Remove the block; use models.physics.turbulence.les, "
8463 "or run laminar."
8464)
8465
8466
8467def validate_wall_model_pairing(case_cfg: dict, les_cfg, wall_cfg,
8468 case_path: str, errors: list, warnings: list):
8469 """!
8470 @brief Rejects wall-model selections that no turbulence treatment can support.
8471
8472 A wall model replaces the near-wall flow with an analytic profile so that the
8473 boundary layer need not be resolved. That only means something if the unresolved
8474 motions are modelled somewhere. Three combinations cannot be, and each fails here
8475 rather than after the mesh is built.
8476
8477 @param case_cfg Parsed case configuration, read for the Reynolds number.
8478 @param les_cfg `models.physics.turbulence.les`, or None.
8479 @param wall_cfg `models.physics.turbulence.wall_function`, or None.
8480 @param case_path Case path, for message prefixes.
8481 @param errors Collected blocking messages, appended to.
8482 @param warnings Collected advisory messages, appended to.
8483 """
8484 if not isinstance(wall_cfg, dict):
8485 return
8486 if not bool(wall_cfg.get('enabled', False)):
8487 return
8488
8489 try:
8490 model = normalize_wall_function_model(wall_cfg.get('model', 'log_law'))
8491 except ValueError:
8492 return # The selector's own validation already reported this.
8493
8494 les_on = isinstance(les_cfg, dict) and bool(les_cfg.get('enabled', False)) \
8495 and str(les_cfg.get('model', 'dynamic_smagorinsky')).strip().lower() != 'none'
8496 # A wall model with nothing to sit on. The convective scheme here is QUICK, whose
8497 # dissipation is linear and upwind-biased; it is not a limiter-based scheme whose
8498 # truncation error stands in for a subgrid stress, so there is no implicit LES to
8499 # appeal to.
8500 if not les_on:
8501 errors.append(
8502 f" {case_path}: models.physics.turbulence.wall_function is enabled with no "
8503 "turbulence model. A wall model supplies the stress of a boundary layer it "
8504 "does not resolve, which needs the unresolved motions modelled somewhere. "
8505 "This solver has no implicit-LES scheme to supply that - its convection is "
8506 "QUICK, whose numerical dissipation is not a subgrid model - so enable "
8507 "models.physics.turbulence.les, or resolve the wall and disable the wall "
8508 "function.")
8509
8510 # A wall law describes a turbulent boundary layer. Below transition there is no
8511 # inertial region for it to stand on, and it would impose a profile the flow does not
8512 # have. The threshold is deliberately far below any transitional value, so that it
8513 # only catches cases that are unambiguously laminar.
8514 reynolds = _case_reynolds_number(case_cfg)
8515 if reynolds is not None and reynolds < 1000.0:
8516 errors.append(
8517 f" {case_path}: models.physics.turbulence.wall_function is enabled at "
8518 f"Reynolds number {reynolds:g}, which is laminar. The log law and the "
8519 "Werner-Wengle power law both describe a turbulent boundary layer; at this "
8520 "Reynolds number there is no inertial region for either to represent, and "
8521 "the model would impose a profile the flow does not have. Resolve the wall "
8522 "instead.")
8523
8524
8525def _case_reynolds_number(case_cfg: dict):
8526 """!
8527 @brief Reynolds number implied by a case's scaling and fluid properties.
8528 @param case_cfg Parsed case configuration.
8529 @return The Reynolds number, or None when the inputs are absent or unusable.
8530 """
8531 try:
8532 props = case_cfg.get('properties', {}) or {}
8533 scaling = props.get('scaling', {}) or {}
8534 fluid = props.get('fluid', {}) or {}
8535 density = float(fluid.get('density'))
8536 viscosity = float(fluid.get('viscosity'))
8537 length_ref = float(scaling.get('length_ref'))
8538 velocity_ref = float(scaling.get('velocity_ref'))
8539 except (TypeError, ValueError):
8540 return None
8541 if viscosity <= 0.0:
8542 return None
8543 return density * velocity_ref * length_ref / viscosity
8544
8545
8546def validate_les_configuration(case_cfg: dict, les_cfg: dict, case_path: str,
8547 errors: list, warnings: list):
8548 """!
8549 @brief Checks the structured LES block for values the closure cannot honour.
8550 @param[in] case_cfg Parsed case.yml mapping, used to read declared periodicity.
8551 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
8552 @param[in] case_path Case file path used to prefix diagnostics.
8553 @param[out] errors List collecting blocking validation failures.
8554 @param[out] warnings List collecting advisory messages.
8555 @return None; findings are appended to `errors` and `warnings`.
8556 """
8557 def _numeric(container, key, path, minimum=None, exclusive_minimum=None):
8558 """!
8559 @brief Reads one numeric key and records a range or type failure against it.
8560 @param[in] container Mapping holding the key.
8561 @param[in] key Key to read; absent keys are accepted silently.
8562 @param[in] path Dotted key path used in diagnostics.
8563 @param[in] minimum Inclusive lower bound, or None to impose none.
8564 @param[in] exclusive_minimum Exclusive lower bound, or None to impose none.
8565 @return The parsed value, or None when the key is absent or unparseable.
8566 """
8567 if key not in container:
8568 return None
8569 try:
8570 value = float(container[key])
8571 except (TypeError, ValueError):
8572 errors.append(f" {case_path}: {path} must be numeric.")
8573 return None
8574 if minimum is not None and value < minimum:
8575 errors.append(f" {case_path}: {path} must be at least {minimum}.")
8576 if exclusive_minimum is not None and value <= exclusive_minimum:
8577 errors.append(f" {case_path}: {path} must be greater than {exclusive_minimum}.")
8578 return value
8579
8580 if 'enabled' in les_cfg and not isinstance(les_cfg['enabled'], bool):
8581 errors.append(f" {case_path}: models.physics.turbulence.les.enabled must be true or false.")
8582
8583 _numeric(les_cfg, 'constant_cs', "models.physics.turbulence.les.constant_cs", minimum=0.0)
8584 _numeric(les_cfg, 'vreman_coefficient', "models.physics.turbulence.les.vreman_coefficient", minimum=0.0)
8585 _numeric(les_cfg, 'wale_coefficient', "models.physics.turbulence.les.wale_coefficient", minimum=0.0)
8586
8587 if 'dynamic_frequency' in les_cfg:
8588 try:
8589 if int(les_cfg['dynamic_frequency']) <= 0:
8590 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
8591 except (TypeError, ValueError):
8592 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
8593
8594 for key, normalizer in (('filter_width', normalize_les_filter_width),):
8595 if key in les_cfg:
8596 try:
8597 normalizer(les_cfg[key])
8598 except ValueError as exc:
8599 errors.append(f" {case_path}: {exc}")
8600
8601 periodic = _les_periodic_axes(case_cfg)
8602
8603 test_filter = les_cfg.get('test_filter')
8604 if test_filter is not None:
8605 if not isinstance(test_filter, dict):
8606 test_filter = {'kernel': test_filter}
8607 if 'kernel' in test_filter:
8608 try:
8609 kernel = normalize_les_test_filter(test_filter['kernel'])
8610 except ValueError as exc:
8611 errors.append(f" {case_path}: {exc}")
8612 else:
8613 # The Simpson stencil collapses onto the central eta-plane, which is
8614 # only a valid average when xi and zeta are homogeneous.
8615 if kernel == 1 and not {"i", "k"} <= periodic:
8616 errors.append(
8617 f" {case_path}: models.physics.turbulence.les.test_filter.kernel "
8618 "'simpson_ik' assumes the xi and zeta directions are homogeneous, but "
8619 "this case does not declare both of them PERIODIC. Use "
8620 "'volume_weighted_box' instead."
8621 )
8622 # A test filter no wider than the grid filter leaves the dynamic procedure with
8623 # nothing to measure, because both terms of the model tensor coincide.
8624 _numeric(test_filter, 'width_ratio',
8625 "models.physics.turbulence.les.test_filter.width_ratio", exclusive_minimum=1.0)
8626
8627 averaging = les_cfg.get('averaging')
8628 if averaging is not None:
8629 if not isinstance(averaging, dict):
8630 averaging = {'mode': averaging}
8631 mode = None
8632 if 'mode' in averaging:
8633 try:
8634 mode = normalize_les_averaging_mode(averaging['mode'])
8635 except ValueError as exc:
8636 errors.append(f" {case_path}: {exc}")
8637 directions = None
8638 if 'directions' in averaging:
8639 try:
8640 directions = normalize_les_averaging_directions(averaging['directions'])
8641 except ValueError as exc:
8642 errors.append(f" {case_path}: {exc}")
8643 else:
8644 if not directions:
8645 errors.append(
8646 f" {case_path}: models.physics.turbulence.les.averaging.directions "
8647 "cannot be empty; omit the key to use the periodic axes."
8648 )
8649 if mode is not None and mode != 1:
8650 errors.append(
8651 f" {case_path}: models.physics.turbulence.les.averaging.directions "
8652 "applies only to mode 'homogeneous'; local and global averaging choose "
8653 "their own directions."
8654 )
8655 for axis in directions:
8656 if axis not in periodic:
8657 warnings.append(
8658 f"{case_path}: models.physics.turbulence.les.averaging.directions "
8659 f"names '{axis}', which this case does not declare PERIODIC. "
8660 "Averaging assumes the flow is statistically homogeneous there."
8661 )
8662 if mode == 1 and directions is None and not periodic:
8663 errors.append(
8664 f" {case_path}: models.physics.turbulence.les.averaging.mode 'homogeneous' "
8665 "derives its directions from the periodic boundary pairs, and this case declares "
8666 "none. Name the directions explicitly or use 'local'."
8667 )
8668
8669 clipping = les_cfg.get('clipping')
8670 if clipping is not None:
8671 if not isinstance(clipping, dict):
8672 clipping = {'mode': clipping}
8673 mode = None
8674 if 'mode' in clipping:
8675 try:
8676 mode = normalize_les_clip_mode(clipping['mode'])
8677 except ValueError as exc:
8678 errors.append(f" {case_path}: {exc}")
8679 _numeric(clipping, 'max_cs', "models.physics.turbulence.les.clipping.max_cs", minimum=0.0)
8680 _numeric(clipping, 'min_viscosity_ratio',
8681 "models.physics.turbulence.les.clipping.min_viscosity_ratio", minimum=0.0)
8682 if 'max_cs' in clipping and mode is not None and mode != 0:
8683 errors.append(
8684 f" {case_path}: models.physics.turbulence.les.clipping.max_cs applies only to "
8685 "mode 'clamp'; 'positive' and 'signed' impose no upper bound. Remove the key or "
8686 "select mode 'clamp'."
8687 )
8688
8689 # The dynamic procedure's controls have no meaning for a prescribed coefficient.
8690 # Only an explicitly named constant model triggers this: a template that documents
8691 # every key while LES is switched off should not be rejected on an inferred default.
8692 try:
8693 model_code = normalize_les_model(les_cfg['model']) if 'model' in les_cfg else None
8694 except ValueError:
8695 model_code = None
8696 if model_code in LES_MODEL_OWNED_KEYS and les_cfg.get('enabled', True):
8697 # Each model's own parameters are refused under any other, so a key that would be
8698 # silently ignored at runtime fails at validation instead. filter_width is shared
8699 # by every model that takes a scalar width; Vreman resolves each direction from
8700 # the cell's own edges and takes none.
8701 model_name = LES_MODEL_NAMES[model_code]
8702 for owner, keys in LES_MODEL_OWNED_KEYS.items():
8703 if owner == model_code:
8704 continue
8705 for key in keys:
8706 if key in les_cfg:
8707 errors.append(
8708 f" {case_path}: models.physics.turbulence.les.{key} configures "
8709 f"{LES_MODEL_KEY_OWNER_DESCRIPTIONS[owner]} and cannot be used with "
8710 f"model '{model_name}'."
8711 )
8712 if model_code == 3 and 'filter_width' in les_cfg:
8713 errors.append(
8714 f" {case_path}: models.physics.turbulence.les.filter_width cannot be used with "
8715 "model 'vreman', which weights each grid direction by the cell's own edge "
8716 "rather than by one scalar width."
8717 )
8718
8719
8721 """!
8722 @brief Validate BC entries against currently supported C-side handlers/types and
8723 @details return normalized entries ready for bcs.run generation.
8724 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
8725 @return Value returned by `validate_and_prepare_boundary_conditions()`.
8726 """
8727 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
8728 scales = resolve_reference_scales(case_cfg)
8729
8730 all_blocks_bcs = normalize_boundary_conditions_layout(case_cfg.get('boundary_conditions', []), num_blocks)
8731 prepared_blocks = []
8732
8733 expected_faces = {"-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta"}
8734 axis_pairs = [("-Xi", "+Xi"), ("-Eta", "+Eta"), ("-Zeta", "+Zeta")]
8735
8736 for bi, block_bcs in enumerate(all_blocks_bcs):
8737 if not isinstance(block_bcs, list):
8738 raise ValueError(f"boundary_conditions[{bi}] must be a list of face configs.")
8739
8740 prepared_block = []
8741 seen_faces = {}
8742
8743 for idx, bc in enumerate(block_bcs):
8744 if not isinstance(bc, dict):
8745 raise ValueError(f"boundary_conditions[{bi}][{idx}] must be a mapping.")
8746
8747 for req in ("face", "type", "handler"):
8748 if req not in bc:
8749 raise ValueError(f"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
8750
8751 face_raw = str(bc["face"]).strip()
8752 face_key = face_raw.lower()
8753 face = BC_FACE_MAP.get(face_key)
8754 if face is None:
8755 raise ValueError(
8756 f"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
8757 f"Supported: {sorted(expected_faces)}."
8758 )
8759 if face in seen_faces:
8760 raise ValueError(f"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
8761 seen_faces[face] = idx
8762
8763 bc_type_raw = str(bc["type"]).strip()
8764 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
8765 # A symmetry plane is a free-slip wall; the separate type was retired.
8766 if bc_type_raw.lower() == "symmetry":
8767 raise ValueError(
8768 f"BC type 'symmetry' for face {face} in block {bi} is retired: a symmetry plane "
8769 "is a free-slip wall, so use type 'wall' with handler 'free_slip'."
8770 )
8771 if bc_type is None:
8772 raise ValueError(
8773 f"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
8774 f"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
8775 )
8776
8777 handler = str(bc["handler"]).strip().lower()
8778 handler_spec = BC_HANDLER_SPECS.get(handler)
8779 if handler_spec is None:
8780 raise ValueError(
8781 f"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
8782 f"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
8783 )
8784 if bc_type not in handler_spec["types"]:
8785 raise ValueError(
8786 f"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
8787 )
8788
8789 params = bc.get("params", {})
8790 if params is None:
8791 params = {}
8792 if not isinstance(params, dict):
8793 raise ValueError(f"'params' for block {bi}, face {face} must be a mapping.")
8794
8795 # Reject unsupported older structured keys explicitly.
8796 if "vector" in params or "velocity" in params:
8797 raise ValueError(
8798 f"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
8799 "Use scalar keys 'vx', 'vy', 'vz'."
8800 )
8801
8802 required = handler_spec["required_params"]
8803 optional = handler_spec["optional_params"]
8804 allowed = required | optional
8805
8806 missing = sorted(required - set(params.keys()))
8807 if missing:
8808 raise ValueError(
8809 f"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
8810 )
8811 unknown = sorted(set(params.keys()) - allowed)
8812 if unknown:
8813 raise ValueError(
8814 f"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
8815 f"Allowed: {sorted(allowed)}."
8816 )
8817
8818 converted_params = {}
8819 for key, value in params.items():
8820 if key in _NUMERIC_BC_PARAMS:
8821 converted_params[key] = quantity_to_solver_units(
8822 value, BC_PARAM_QUANTITIES[key], scales,
8823 f"boundary_conditions[{bi}][{idx}].params.{key}")
8824 elif key in _BOOL_BC_PARAMS:
8825 canonical = _DEPRECATED_BC_PARAM_ALIASES.get(key, key)
8826 if canonical != key:
8827 if canonical in params:
8828 raise ValueError(
8829 f"boundary_conditions[{bi}][{idx}].params sets both '{key}' and its "
8830 f"replacement '{canonical}'. Use '{canonical}' only."
8831 )
8832 print(
8833 f"[WARNING] boundary_conditions[{bi}][{idx}].params.{key} is deprecated; "
8834 f"use '{canonical}'. It enables the local seam-flux correction on the "
8835 "periodic boundary plane, on top of the body force that sustains the bulk "
8836 "flow. See docs/pages/54_Geometric_Periodic_Boundaries.md.",
8837 file=sys.stderr,
8838 )
8839 converted_params[canonical] = _to_bool(
8840 value, f"boundary_conditions[{bi}][{idx}].params.{key}")
8841 elif handler == "prescribed_flow" and key == "source":
8842 converted_params[key] = _normalize_prescribed_flow_source(
8843 value, f"boundary_conditions[{bi}][{idx}].params.source"
8844 )
8845 else:
8846 # Defensive fallback; should not happen due unknown-key gate above.
8847 converted_params[key] = value
8848
8849 policy = str(bc.get("particle_policy", handler_spec["particle_policy"])).strip().lower()
8850 if policy not in PARTICLE_BOUNDARY_POLICIES:
8851 raise ValueError(
8852 f"Unsupported particle_policy '{bc.get('particle_policy')}' for face {face} in block {bi}. "
8853 f"Supported: {list(PARTICLE_BOUNDARY_POLICIES)}."
8854 )
8855 if (policy == "wrap") != (bc_type == "PERIODIC"):
8856 raise ValueError(
8857 f"boundary_conditions[{bi}][{idx}].particle_policy '{policy}' does not fit a {bc_type} face: "
8858 "PERIODIC faces wrap particles to the paired face, and only they can."
8859 )
8860 converted_params["particle_policy"] = policy
8861
8862 prepared_block.append({
8863 "face": face,
8864 "type": bc_type,
8865 "handler": handler,
8866 "params": converted_params,
8867 })
8868
8869 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
8870 if missing_faces:
8871 raise ValueError(
8872 f"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
8873 "Provide all six faces explicitly."
8874 )
8875
8876 # Pairwise periodic consistency checks.
8877 face_map = {entry["face"]: entry for entry in prepared_block}
8878 for neg_face, pos_face in axis_pairs:
8879 neg = face_map[neg_face]
8880 pos = face_map[pos_face]
8881 neg_periodic = (neg["type"] == "PERIODIC")
8882 pos_periodic = (pos["type"] == "PERIODIC")
8883 if neg_periodic != pos_periodic:
8884 raise ValueError(
8885 f"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
8886 )
8887
8888 driven_handlers = {"constant_flux", "initial_flux"}
8889 if (neg["handler"] in driven_handlers) or (pos["handler"] in driven_handlers):
8890 if neg["handler"] != pos["handler"]:
8891 raise ValueError(
8892 f"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
8893 )
8894 if not (neg_periodic and pos_periodic):
8895 raise ValueError(
8896 f"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
8897 )
8898
8899 prepared_blocks.append(prepared_block)
8900
8901 return prepared_blocks
8902
8903
8904def _schema_path_text(path: tuple) -> str:
8905 """!
8906 @brief Render an internal schema path tuple as a user-facing YAML path.
8907 @param[in] path Internal path tuple.
8908 @return Dotted YAML path.
8909 """
8910 return ".".join(part for part in path if part != "[]") or "<root>"
8911
8912
8913def _lookup_allowed_schema_keys(schema: dict, path: tuple):
8914 """!
8915 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
8916 @param[in] schema Role schema mapping.
8917 @param[in] path Internal path tuple.
8918 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
8919 """
8920 if path in schema:
8921 return schema[path]
8922 for idx, part in enumerate(path):
8923 if part == "[]":
8924 continue
8925 candidate = path[:idx] + ("*",) + path[idx + 1:]
8926 if candidate in schema:
8927 return schema[candidate]
8928 return False
8929
8930
8931def _schema_key_hint(schema: dict, path: tuple, key: str, allowed: set) -> str:
8932 """!
8933 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
8934 @param[in] schema Role schema mapping.
8935 @param[in] path Current internal YAML path tuple.
8936 @param[in] key Unsupported YAML key.
8937 @param[in] allowed Allowed keys at the current path.
8938 @return Optional hint string.
8939 """
8940 hints = []
8941 allowed_strings = sorted(str(item) for item in allowed)
8942 lower_matches = [item for item in allowed_strings if item.lower() == key.lower()]
8943 close_matches = lower_matches or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
8944 if close_matches:
8945 hints.append(f"Did you mean '{close_matches[0]}'?")
8946
8947 valid_paths = []
8948 for schema_path, schema_allowed in schema.items():
8949 if schema_path == path or not schema_allowed:
8950 continue
8951 if key in schema_allowed:
8952 valid_paths.append(_schema_path_text(schema_path))
8953 if valid_paths:
8954 hints.append(f"This key is valid at: {', '.join(sorted(valid_paths))}.")
8955
8956 return " ".join(hints)
8957
8958
8959def _validate_yaml_schema_keys(cfg, schema: dict, file_path: str, errors: list, path: tuple = ()) -> None:
8960 """!
8961 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
8962 @param[in] cfg Parsed YAML node.
8963 @param[in] schema Role schema mapping.
8964 @param[in] file_path Source file path for diagnostics.
8965 @param[in,out] errors Validation error accumulator.
8966 @param[in] path Current internal YAML path tuple.
8967 """
8968 if isinstance(cfg, dict):
8969 allowed = _lookup_allowed_schema_keys(schema, path)
8970 if allowed is not False and allowed is not None:
8971 unknown = sorted(str(key) for key in cfg.keys() if key not in allowed)
8972 for key in unknown:
8973 hint = _schema_key_hint(schema, path, key, allowed)
8974 hint_text = f" {hint}" if hint else ""
8975 errors.append(
8976 f" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
8977 f"Allowed keys: {sorted(allowed)}.{hint_text}"
8978 )
8979 if allowed is None:
8980 return
8981 for key, value in cfg.items():
8982 _validate_yaml_schema_keys(value, schema, file_path, errors, path + (key,))
8983 elif isinstance(cfg, list) and _lookup_allowed_schema_keys(schema, path + ("[]",)) is not False:
8984 for item in cfg:
8985 _validate_yaml_schema_keys(item, schema, file_path, errors, path + ("[]",))
8986 elif (isinstance(cfg, (str, int, float, list)) and not isinstance(cfg, bool)
8987 and path and path[-1] != "[]" and _lookup_allowed_schema_keys(schema, path) is not False):
8988 # A section given as a string, number or list would reach code that reads it as a
8989 # mapping. A boolean is left alone: several sections take true/false as shorthand
8990 # (models.physics.turbulence.les, io.paraview_series). So are list items, some of
8991 # which accept a scalar shorthand (a study metric name).
8992 errors.append(
8993 f" {file_path}: '{_schema_path_text(path)}' must be a mapping, not "
8994 f"{type(cfg).__name__} {cfg!r}."
8995 )
8996 elif isinstance(cfg, list):
8997 for item in cfg:
8998 _validate_yaml_schema_keys(item, schema, file_path, errors, path + ("[]",))
8999
9000
9001_CASE_SCHEMA = {
9002 (): {
9003 "title", "properties", "run_control", "grid", "models", "boundary_conditions", "solver_parameters",
9004 },
9005 ("run_control",): {"start_step", "total_steps", "dt_physical"},
9006 ("properties",): {"scaling", "fluid", "initial_conditions"},
9007 ("properties", "scaling"): {"length_ref", "velocity_ref"},
9008 ("properties", "fluid"): {"density", "viscosity", "schmidt_number"},
9009 ("properties", "initial_conditions"): {
9010 "mode", "generator", "params", "field", "source_file",
9011 "source_case", "velocity_scale", "length_scale",
9012 "u_physical", "v_physical", "w_physical", "peak_velocity_physical",
9013 "velocity_physical", "flow_direction",
9014 },
9015 ("properties", "initial_conditions", "params"): None,
9016 ("grid",): {
9017 "mode", "source_file", "programmatic_settings", "generator",
9018 "da_processors_x", "da_processors_y", "da_processors_z",
9019 },
9020 ("grid", "programmatic_settings"): {
9021 "im", "jm", "km", "xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs",
9022 "rxs", "rys", "rzs", "cgrids",
9023 "da_processors_x", "da_processors_y", "da_processors_z",
9024 },
9025 ("grid", "generator"): {
9026 "script", "config_file", "grid_type", "cli_args", "output_file", "stats_file", "vts_file",
9027 # Retained so existing warning behavior for typo-prone hyphen keys is not bypassed.
9028 "config-file", "grid-type", "output-file", "stats-file", "vts-file",
9029 },
9030 ("models",): {"domain", "physics"},
9031 ("models", "domain"): {"blocks"},
9032 ("models", "physics"): {"dimensionality", "fsi", "particles", "turbulence"},
9033 ("models", "physics", "fsi"): {"immersed", "moving_fsi"},
9034 ("models", "physics", "particles"): {"count", "init_mode", "restart_mode", "point_source", "random_seed",
9035 "fields", "iem_constant"},
9036 ("models", "physics", "particles", "fields"): None,
9037 ("models", "physics", "particles", "point_source"): {"x", "y", "z"},
9038 # 'rans' stays listed so a case carrying it gets the planned-status refusal rather
9039 # than a generic unknown-key error; nothing translates it.
9040 ("models", "physics", "turbulence"): {"les", "rans", "wall_function", "turbulent_schmidt_number"},
9041 ("models", "physics", "turbulence", "les"): {
9042 "enabled", "model", "constant_cs", "vreman_coefficient", "wale_coefficient",
9043 "dynamic_frequency", "filter_width",
9044 "test_filter", "averaging", "clipping", "gradient_model", "diagnostics",
9045 },
9046 ("models", "physics", "turbulence", "les", "test_filter"): {"kernel", "width_ratio"},
9047 ("models", "physics", "turbulence", "les", "averaging"): {"mode", "directions"},
9048 ("models", "physics", "turbulence", "les", "clipping"): {
9049 "mode", "max_cs", "min_viscosity_ratio",
9050 },
9051 ("models", "physics", "turbulence", "les", "gradient_model"): {"enabled"},
9052 ("models", "physics", "turbulence", "les", "diagnostics"): {
9053 "enabled", "cadence", "yoshizawa_ci",
9054 },
9055 ("models", "physics", "turbulence", "rans"): {"enabled", "model"},
9056 ("models", "physics", "turbulence", "wall_function"): {"enabled", "model", "roughness_height"},
9057 ("boundary_conditions", "[]"): {"face", "type", "handler", "params", "particle_policy"},
9058 ("boundary_conditions", "[]", "[]"): {"face", "type", "handler", "params", "particle_policy"},
9059 ("boundary_conditions", "[]", "params"): None,
9060 ("boundary_conditions", "[]", "[]", "params"): None,
9061 ("solver_parameters",): None,
9062}
9063
9064
9065_SOLVER_SCHEMA = {
9066 (): {
9067 "operation_mode", "strategy", "tolerances", "momentum_solver", "poisson_solver",
9068 "pressure_solver", "interpolation", "petsc_passthrough_options", "verification",
9069 "scalar_transport",
9070 },
9071 ("operation_mode",): {"eulerian_field_source", "analytical_type", "uniform_flow"},
9072 ("operation_mode", "uniform_flow"): {"u", "v", "w"},
9073 ("strategy",): {"momentum_solver", "central_diff"},
9074 ("tolerances",): {
9075 "max_iterations", "absolute_tol", "relative_tol", "step_tol",
9076 "residual_absolute_tol", "residual_relative_tol",
9077 },
9078 ("momentum_solver",): {
9079 "type", "dual_time_picard_jameson_rk", "dual_time_picard_rk4", "newton_krylov",
9080 },
9081 ("momentum_solver", "dual_time_picard_jameson_rk"): {
9082 "max_pseudo_steps", "absolute_tol", "relative_tol", "step_tol", "pseudo_cfl",
9083 "jameson_residual_noise_allowance_factor", "rk4_residual_noise_allowance_factor",
9084 "ratio_ema_alpha",
9085 },
9086 ("momentum_solver", "dual_time_picard_jameson_rk", "pseudo_cfl"): {
9087 "initial", "minimum", "maximum", "growth_factor", "reduction_factor",
9088 },
9089 ("momentum_solver", "dual_time_picard_rk4"): {
9090 "max_pseudo_steps", "absolute_tol", "relative_tol", "step_tol", "pseudo_cfl",
9091 "jameson_residual_noise_allowance_factor", "rk4_residual_noise_allowance_factor",
9092 "ratio_ema_alpha",
9093 },
9094 ("momentum_solver", "dual_time_picard_rk4", "pseudo_cfl"): {
9095 "initial", "minimum", "maximum", "growth_factor", "reduction_factor",
9096 },
9097 ("momentum_solver", "newton_krylov"): {
9098 "jacobian", "preconditioner", "nonlinear_solver", "linear_solver",
9099 },
9100 ("momentum_solver", "newton_krylov", "jacobian"): {"type", "finite_difference"},
9101 ("momentum_solver", "newton_krylov", "jacobian", "finite_difference"): {"mode"},
9102 ("momentum_solver", "newton_krylov", "preconditioner"): {"model", "structure"},
9103 ("momentum_solver", "newton_krylov", "preconditioner", "structure"): {"type"},
9104 ("momentum_solver", "newton_krylov", "nonlinear_solver"): {
9105 "method", "absolute_tolerance", "relative_tolerance", "step_tolerance",
9106 "max_iterations", "line_search", "eisenstat_walker",
9107 },
9108 ("momentum_solver", "newton_krylov", "nonlinear_solver", "line_search"): {"type"},
9109 ("momentum_solver", "newton_krylov", "nonlinear_solver", "eisenstat_walker"): {
9110 "enabled", "version", "initial_relative_tolerance", "maximum_relative_tolerance",
9111 "gamma", "exponent", "safeguard_exponent", "safeguard_threshold",
9112 },
9113 ("momentum_solver", "newton_krylov", "linear_solver"): {
9114 "method", "absolute_tolerance", "relative_tolerance", "max_iterations",
9115 "gmres", "preconditioner",
9116 },
9117 ("momentum_solver", "newton_krylov", "linear_solver", "gmres"): {"restart"},
9118 ("momentum_solver", "newton_krylov", "linear_solver", "preconditioner"): {"type"},
9119 ("poisson_solver",): {
9120 "method", "absolute_tolerance", "relative_tolerance", "max_iterations", "tolerance",
9121 "gmres", "preconditioner", "multigrid",
9122 },
9123 ("pressure_solver",): {
9124 "method", "absolute_tolerance", "relative_tolerance", "max_iterations", "tolerance",
9125 "gmres", "preconditioner", "multigrid",
9126 },
9127 ("poisson_solver", "gmres"): {"restart"},
9128 ("pressure_solver", "gmres"): {"restart"},
9129 ("poisson_solver", "preconditioner"): {"type"},
9130 ("pressure_solver", "preconditioner"): {"type"},
9131 ("poisson_solver", "multigrid"): {
9132 "levels", "pre_sweeps", "post_sweeps", "cycle", "mode", "semi_coarsening", "level_solvers",
9133 },
9134 ("pressure_solver", "multigrid"): {
9135 "levels", "pre_sweeps", "post_sweeps", "cycle", "mode", "semi_coarsening", "level_solvers",
9136 },
9137 ("poisson_solver", "multigrid", "semi_coarsening"): {"i", "j", "k"},
9138 ("pressure_solver", "multigrid", "semi_coarsening"): {"i", "j", "k"},
9139 ("poisson_solver", "multigrid", "level_solvers", "*"): {
9140 "method", "preconditioner", "ksp_type", "pc_type", "max_it", "rtol", "atol",
9141 },
9142 ("pressure_solver", "multigrid", "level_solvers", "*"): {
9143 "method", "preconditioner", "ksp_type", "pc_type", "max_it", "rtol", "atol",
9144 },
9145 ("interpolation",): {"method"},
9146 ("petsc_passthrough_options",): None,
9147 ("verification",): {"sources"},
9148 ("verification", "sources"): {"diffusivity", "scalar"},
9149 ("verification", "sources", "diffusivity"): {"mode", "profile", "gamma0", "slope_x"},
9150 ("verification", "sources", "scalar"): {
9151 "mode", "profile", "value", "phi0", "slope_x", "amplitude", "kx", "ky", "kz",
9152 },
9153 ("scalar_transport",): set(SCALAR_TRANSPORT_FLAGS),
9154}
9155
9156
9157_MONITOR_SCHEMA = {
9158 (): {
9159 "logging", "profiling", "diagnostics", "io", "solver_monitoring", "solution_monitoring",
9160 "field_statistics",
9161 },
9162 ("logging",): {"verbosity", "enabled_functions", "trace_particles"},
9163 ("profiling",): {"timestep_output", "final_summary", "helpers"},
9164 ("profiling", "timestep_output"): {"mode", "functions"},
9165 ("profiling", "final_summary"): {"enabled"},
9166 ("diagnostics",): {"petsc", "runtime_memory_log", "les"},
9167 ("diagnostics", "les"): {"enabled", "cadence", "yoshizawa_ci"},
9168 ("diagnostics", "petsc"): {
9169 "info", "malloc_debug", "malloc_test", "malloc_dump", "malloc_view", "malloc_view_threshold",
9170 "memory_view", "log_view", "log_view_memory", "log_all", "log_trace",
9171 "objects_dump", "options_left",
9172 },
9173 ("diagnostics", "petsc", "info"): {"enabled", "classes"},
9174 ("diagnostics", "runtime_memory_log"): {"enabled"},
9175 ("io",): {
9176 "data_output_frequency", "particle_console_output_frequency", "particle_log_interval",
9177 "statistics_console_output_frequency", "write_initial_checkpoint", "write_final_checkpoint",
9178 },
9179 ("solver_monitoring",): {"momentum", "poisson", "petsc_passthrough_options"},
9180 ("solver_monitoring", "momentum"): {
9181 "newton_krylov_history", "snes_monitor", "snes_converged_reason",
9182 "ksp_monitor", "ksp_converged_reason",
9183 },
9184 ("solver_monitoring", "poisson"): {"pic_true_residual", "true_residual", "converged_reason", "view"},
9185 ("solver_monitoring", "petsc_passthrough_options"): None,
9186 ("solution_monitoring",): {"convergence"},
9187 ("solution_monitoring", "convergence"): {
9188 "enabled", "mode", "periodic_deterministic", "statistical_steady",
9189 },
9190 ("solution_monitoring", "convergence", "periodic_deterministic"): {"period_steps"},
9191 ("solution_monitoring", "convergence", "statistical_steady"): {"window_steps"},
9192 ("field_statistics",): {"enabled", "windows"},
9193 ("field_statistics", "windows", "[]"): {
9194 "name", "start_time", "end_time", "weighting",
9195 "step_cadence", "time_cadence", "fields", "covariances",
9196 },
9197 ("field_statistics", "windows", "[]", "fields", "[]"): {"field", "moments"},
9198}
9199
9200
9201#: Eulerian fields a statistics window may accumulate, with the subsystem each needs.
9202#:
9203#: This is a curated subset of the typed catalog rather than a mirror of it. Averaging
9204#: a grid metric is meaningless, and a face-staggered field has no single pointwise
9205#: location, so offering the whole catalog would only move the rejection later and
9206#: make it harder to read. `tests/test_cli_smoke.py` asserts every entry here still
9207#: exists in `src/field_catalog.c` with the layout recorded, so the two cannot drift.
9208STATISTICS_ELIGIBLE_FIELDS = {
9209 "Ucat": {"components": 3, "requires": None},
9210 "P": {"components": 1, "requires": None},
9211 "Nvert": {"components": 1, "requires": None},
9212 "Phi": {"components": 1, "requires": None},
9213 "Psi": {"components": 1, "requires": "particles"},
9214 "ParticleCount": {"components": 1, "requires": "particles"},
9215 "Nu_t": {"components": 1, "requires": "turbulence"},
9216 "CS": {"components": 1, "requires": "les"},
9217}
9218
9219#: Particle fields a recipe ensemble may sample. Each is accumulated onto the Eulerian
9220#: field its particle-catalog entry scatters to, which also names its derived outputs.
9221STATISTICS_PARTICLE_FIELDS = {
9222 "Psi": {"components": 1, "requires": "particles", "target": "Psi"},
9223 "velocity": {"components": 3, "requires": "particles", "target": "Ucat"},
9224}
9225
9226#: Sources a recipe ensemble may draw its samples from: the particles in each cell, or
9227#: each cell's own state.
9228POST_STATISTICS_ENSEMBLE_SOURCES = ("particles", "eulerian")
9229
9230
9231def _ensemble_source_fields(source: str) -> dict:
9232 """!
9233 @brief Return the field table an ensemble of one source names its fields from.
9234 @param[in] source One of `POST_STATISTICS_ENSEMBLE_SOURCES`.
9235 @return The particle or the Eulerian statistics field table.
9236 """
9237 return STATISTICS_PARTICLE_FIELDS if source == "particles" else STATISTICS_ELIGIBLE_FIELDS
9238
9239
9240#: Moment names one field may request. Higher moments cannot be recovered from
9241#: centered state after the fact, so they need their own accumulation rather than
9242#: an extension of this list.
9243STATISTICS_MOMENT_NAMES = ("first", "second")
9244
9245#: Weighting modes a window may select.
9246STATISTICS_WEIGHTING_MODES = ("sample", "physical_time")
9247
9248
9249_POST_SCHEMA = {
9250 (): {
9251 "run_control", "source_data", "global_operations", "eulerian_pipeline",
9252 "lagrangian_pipeline", "statistics_pipeline", "statistics_output_prefix",
9253 "field_statistics", "spectra", "reducers", "io",
9254 },
9255 ("field_statistics",): {"windows", "ensembles", "homogeneous", "source_step", "outputs", "formats"},
9256 ("field_statistics", "ensembles", "[]"): {"name", "source", "fields", "covariances"},
9257 ("field_statistics", "ensembles", "[]", "fields", "[]"): {"field", "moments"},
9258 ("spectra",): {"output_prefix", "tasks"},
9259 ("reducers",): {"tasks"},
9260 ("reducers", "tasks", "[]"): {
9261 "task", "source", "wall_axis", "stream_axis", "friction_velocity", "value", "fold", "half_height",
9262 },
9263 ("spectra", "tasks", "[]"): {
9264 "task", "field", "block", "symbol", "subtract_mean", "mean_source_step",
9265 "reference", "axes", "fixed_indices",
9266 },
9267 ("run_control",): {
9268 "start_step", "end_step", "step_interval", "startTime", "endTime", "timeStep",
9269 },
9270 ("source_data",): {"directory", "input_extensions"},
9271 ("source_data", "input_extensions"): {"eulerian", "particle"},
9272 ("global_operations",): {"dimensionalize"},
9273 ("eulerian_pipeline", "[]"): {"task", "input_field", "output_field", "field", "reference_point"},
9274 ("lagrangian_pipeline", "[]"): {"task", "input_field", "output_field"},
9275 ("statistics_pipeline",): {"output_prefix", "tasks"},
9276 ("statistics_pipeline", "tasks", "[]"): {"task"},
9277 ("io",): {
9278 "output_directory", "output_filename_prefix", "particle_filename_prefix", "output_particles",
9279 "particle_subsampling_frequency", "input_extensions",
9280 "eulerian_fields", "particle_fields", "paraview_series",
9281 },
9282 ("io", "input_extensions"): {"eulerian", "particle"},
9283 ("io", "paraview_series"): {"enabled", "scope"},
9284}
9285
9286
9287_CLUSTER_SCHEMA = {
9288 (): {"scheduler", "resources", "notifications", "execution"},
9289 ("scheduler",): {"type"},
9290 ("resources",): {"account", "partition", "nodes", "ntasks_per_node", "mem", "time"},
9291 ("notifications",): {"mail_user", "mail_type"},
9292 ("execution",): {
9293 "module_setup", "launcher", "launcher_args", "extra_sbatch", "walltime_guard", "decomposition",
9294 },
9295 ("execution", "decomposition"): {"i", "j", "k"},
9296 ("execution", "extra_sbatch"): None,
9297 ("execution", "walltime_guard"): {
9298 "enabled", "warmup_steps", "multiplier", "min_seconds", "estimator_alpha",
9299 },
9300}
9301
9302
9303_STUDY_SCHEMA = {
9304 (): {
9305 "title", "base_configs", "study_type", "parameters", "parameter_sets", "metrics", "plotting", "execution",
9306 },
9307 ("base_configs",): {"case", "solver", "monitor", "post"},
9308 ("parameters",): None,
9309 ("parameter_sets", "[]"): None,
9310 ("metrics", "[]"): {
9311 "name", "source", "file_glob", "column", "regex", "reduction", "normalize_by_parameter",
9312 "numerator_column", "denominator_column", "denominator_floor",
9313 "plot_label", "label", "units",
9314 },
9315 ("plotting",): {"enabled", "output_format"},
9316 ("execution",): {"max_concurrent_array_tasks"},
9317}
9318
9319
9320_WORKSPACE_SCHEMA = {
9321 (): {"schema_version", "workspace", "software", "paths", "reproducibility"},
9322 ("workspace",): {"id", "template", "created_at"},
9323 ("software",): {"picurv"},
9324 ("paths",): {"config", "inputs", "assets", "runs", "studies"},
9325 ("reproducibility",): {"require_clean_release", "pin_executables"},
9326}
9327
9328
9329# Directories the run owns and writes into. `log` is the safety-critical one: the C
9330# runtime calls PetscRMTree on it at the start of a fresh solve, so a value that
9331# escapes the run directory means the solver recursively deletes whatever is there.
9332#: Physical dimensions, as exponents of the reference length, velocity, and density.
9333#:
9334#: Every input a user writes is physical, and it becomes a solver value by one division,
9335#: by `length_ref**a * velocity_ref**b * density**c` for dimension `(a, b, c)`. The C
9336#: field catalogs carry the same triple for the reverse conversion in post-processing,
9337#: so the two directions use one vocabulary. See docs/pages/19_Nondimensionalization.md.
9338DIMENSIONLESS = (0, 0, 0)
9339LENGTH = (1, 0, 0)
9340VELOCITY = (0, 1, 0)
9341TIME = (1, -1, 0)
9342WAVENUMBER = (-1, 0, 0)
9343VOLUME_FLUX = (2, 1, 0)
9344DIFFUSIVITY = (1, 1, 0)
9345PRESSURE = (0, 2, 1)
9346DENSITY = (0, 0, 1)
9347DYNAMIC_VISCOSITY = (1, 1, 1)
9348
9349#: Where an input's conversion to solver units happens. `cli` converts the value while
9350#: writing the control file; `c` converts it in the runtime (programmatic grid bounds,
9351#: which have no staging step); `staging` scales a file payload as it is staged; `provider`
9352#: means a generator receives the reference scales and emits solver units; `reference`
9353#: marks the inputs that define the scales themselves; `passthrough` marks raw solver
9354#: flags, which are expert input in solver units by definition; `evaluator` means the
9355#: runtime evaluates an expression at physical coordinates and divides its value by the
9356#: target field's reference scale. An empty string means there is nothing to convert.
9357INPUT_CONVERSION_SITES = ("cli", "c", "staging", "provider", "evaluator", "reference", "passthrough", "")
9358
9359NOT_A_QUANTITY = (None, "")
9360UNITLESS = (DIMENSIONLESS, "")
9361
9362#: The physical dimension of every configuration input, and where it is converted.
9363#:
9364#: Keys are the configuration role followed by the same path tuples the key schemas use.
9365#: An entry for an interior path covers every leaf beneath it; that form is reserved for
9366#: subtrees that are inherently free of physical quantities (numerical controls, logging,
9367#: selectors). `tests/test_units_and_scaling.py` requires every schema leaf, every BC
9368#: handler parameter, and every initial-condition generator parameter to be covered, so a
9369#: new key cannot be added without deciding what it measures.
9370INPUT_QUANTITIES = {
9371 # ----- case.yml -----
9372 ("case", "title"): NOT_A_QUANTITY,
9373 ("case", "run_control", "start_step"): NOT_A_QUANTITY,
9374 ("case", "run_control", "total_steps"): NOT_A_QUANTITY,
9375 ("case", "run_control", "dt_physical"): (TIME, "cli"),
9376 ("case", "properties", "scaling", "length_ref"): (LENGTH, "reference"),
9377 ("case", "properties", "scaling", "velocity_ref"): (VELOCITY, "reference"),
9378 ("case", "properties", "fluid", "density"): (DENSITY, "reference"),
9379 ("case", "properties", "fluid", "viscosity"): (DYNAMIC_VISCOSITY, "reference"),
9380 ("case", "properties", "fluid", "schmidt_number"): UNITLESS,
9381 ("case", "properties", "initial_conditions", "mode"): NOT_A_QUANTITY,
9382 ("case", "properties", "initial_conditions", "generator"): NOT_A_QUANTITY,
9383 ("case", "properties", "initial_conditions", "field"): NOT_A_QUANTITY,
9384 ("case", "properties", "initial_conditions", "flow_direction"): NOT_A_QUANTITY,
9385 # The payload's dimension follows its field: velocity for Ucat, volume flux for Ucont.
9386 ("case", "properties", "initial_conditions", "source_file"): (None, "staging"),
9387 ("case", "properties", "initial_conditions", "source_case"): NOT_A_QUANTITY,
9388 ("case", "properties", "initial_conditions", "velocity_scale"): (VELOCITY, "reference"),
9389 ("case", "properties", "initial_conditions", "length_scale"): (LENGTH, "reference"),
9390 ("case", "properties", "initial_conditions", "u_physical"): (VELOCITY, "cli"),
9391 ("case", "properties", "initial_conditions", "v_physical"): (VELOCITY, "cli"),
9392 ("case", "properties", "initial_conditions", "w_physical"): (VELOCITY, "cli"),
9393 ("case", "properties", "initial_conditions", "velocity_physical"): (VELOCITY, "cli"),
9394 ("case", "properties", "initial_conditions", "peak_velocity_physical"): (VELOCITY, "cli"),
9395 ("case", "grid", "mode"): NOT_A_QUANTITY,
9396 ("case", "grid", "source_file"): (LENGTH, "staging"),
9397 ("case", "grid", "da_processors_x"): NOT_A_QUANTITY,
9398 ("case", "grid", "da_processors_y"): NOT_A_QUANTITY,
9399 ("case", "grid", "da_processors_z"): NOT_A_QUANTITY,
9400 ("case", "grid", "programmatic_settings", "im"): NOT_A_QUANTITY,
9401 ("case", "grid", "programmatic_settings", "jm"): NOT_A_QUANTITY,
9402 ("case", "grid", "programmatic_settings", "km"): NOT_A_QUANTITY,
9403 ("case", "grid", "programmatic_settings", "cgrids"): NOT_A_QUANTITY,
9404 ("case", "grid", "programmatic_settings", "da_processors_x"): NOT_A_QUANTITY,
9405 ("case", "grid", "programmatic_settings", "da_processors_y"): NOT_A_QUANTITY,
9406 ("case", "grid", "programmatic_settings", "da_processors_z"): NOT_A_QUANTITY,
9407 ("case", "grid", "programmatic_settings", "rxs"): UNITLESS,
9408 ("case", "grid", "programmatic_settings", "rys"): UNITLESS,
9409 ("case", "grid", "programmatic_settings", "rzs"): UNITLESS,
9410 ("case", "grid", "programmatic_settings", "xMins"): (LENGTH, "c"),
9411 ("case", "grid", "programmatic_settings", "xMaxs"): (LENGTH, "c"),
9412 ("case", "grid", "programmatic_settings", "yMins"): (LENGTH, "c"),
9413 ("case", "grid", "programmatic_settings", "yMaxs"): (LENGTH, "c"),
9414 ("case", "grid", "programmatic_settings", "zMins"): (LENGTH, "c"),
9415 ("case", "grid", "programmatic_settings", "zMaxs"): (LENGTH, "c"),
9416 # grid.gen writes a physical PICGRID, which is staged like a file grid.
9417 ("case", "grid", "generator"): (LENGTH, "staging"),
9418 ("case", "models", "domain", "blocks"): NOT_A_QUANTITY,
9419 ("case", "models", "physics", "dimensionality"): NOT_A_QUANTITY,
9420 ("case", "models", "physics", "fsi"): NOT_A_QUANTITY,
9421 ("case", "models", "physics", "particles", "count"): NOT_A_QUANTITY,
9422 ("case", "models", "physics", "particles", "init_mode"): NOT_A_QUANTITY,
9423 ("case", "models", "physics", "particles", "restart_mode"): NOT_A_QUANTITY,
9424 ("case", "models", "physics", "particles", "random_seed"): NOT_A_QUANTITY,
9425 ("case", "models", "physics", "particles", "iem_constant"): UNITLESS,
9426 ("case", "models", "physics", "particles", "point_source", "x"): (LENGTH, "cli"),
9427 ("case", "models", "physics", "particles", "point_source", "y"): (LENGTH, "cli"),
9428 ("case", "models", "physics", "particles", "point_source", "z"): (LENGTH, "cli"),
9429 # Expressions at physical coordinates; each value is in its field's physical units.
9430 ("case", "models", "physics", "particles", "fields"): (None, "evaluator"),
9431 # Model constants, ratios, cadences, and selectors: nothing here carries a unit.
9432 ("case", "models", "physics", "turbulence", "les"): UNITLESS,
9433 ("case", "models", "physics", "turbulence", "rans"): NOT_A_QUANTITY,
9434 ("case", "models", "physics", "turbulence", "turbulent_schmidt_number"): UNITLESS,
9435 ("case", "models", "physics", "turbulence", "wall_function", "enabled"): NOT_A_QUANTITY,
9436 ("case", "models", "physics", "turbulence", "wall_function", "model"): NOT_A_QUANTITY,
9437 ("case", "models", "physics", "turbulence", "wall_function", "roughness_height"): (LENGTH, "cli"),
9438 ("case", "boundary_conditions", "[]", "face"): NOT_A_QUANTITY,
9439 ("case", "boundary_conditions", "[]", "type"): NOT_A_QUANTITY,
9440 ("case", "boundary_conditions", "[]", "handler"): NOT_A_QUANTITY,
9441 ("case", "boundary_conditions", "[]", "particle_policy"): NOT_A_QUANTITY,
9442 ("case", "boundary_conditions", "[]", "[]", "face"): NOT_A_QUANTITY,
9443 ("case", "boundary_conditions", "[]", "[]", "type"): NOT_A_QUANTITY,
9444 ("case", "boundary_conditions", "[]", "[]", "handler"): NOT_A_QUANTITY,
9445 ("case", "boundary_conditions", "[]", "[]", "particle_policy"): NOT_A_QUANTITY,
9446 ("case", "solver_parameters"): (None, "passthrough"),
9447 # ----- solver.yml -----
9448 ("solver", "operation_mode", "eulerian_field_source"): NOT_A_QUANTITY,
9449 ("solver", "operation_mode", "analytical_type"): NOT_A_QUANTITY,
9450 ("solver", "operation_mode", "uniform_flow", "u"): (VELOCITY, "cli"),
9451 ("solver", "operation_mode", "uniform_flow", "v"): (VELOCITY, "cli"),
9452 ("solver", "operation_mode", "uniform_flow", "w"): (VELOCITY, "cli"),
9453 # Iteration controls act on residuals the solver forms in its own units.
9454 ("solver", "strategy"): NOT_A_QUANTITY,
9455 ("solver", "tolerances"): UNITLESS,
9456 ("solver", "momentum_solver"): UNITLESS,
9457 ("solver", "poisson_solver"): UNITLESS,
9458 ("solver", "pressure_solver"): UNITLESS,
9459 ("solver", "interpolation"): NOT_A_QUANTITY,
9460 ("solver", "petsc_passthrough_options"): (None, "passthrough"),
9461 ("solver", "verification", "sources", "diffusivity", "mode"): NOT_A_QUANTITY,
9462 ("solver", "verification", "sources", "diffusivity", "profile"): NOT_A_QUANTITY,
9463 ("solver", "verification", "sources", "diffusivity", "gamma0"): (DIFFUSIVITY, "cli"),
9464 # Gamma = gamma0 + slope_x * x, so the slope is a diffusivity per length.
9465 ("solver", "verification", "sources", "diffusivity", "slope_x"): (VELOCITY, "cli"),
9466 ("solver", "verification", "sources", "scalar", "mode"): NOT_A_QUANTITY,
9467 ("solver", "verification", "sources", "scalar", "profile"): NOT_A_QUANTITY,
9468 ("solver", "verification", "sources", "scalar", "value"): UNITLESS,
9469 ("solver", "verification", "sources", "scalar", "phi0"): UNITLESS,
9470 ("solver", "verification", "sources", "scalar", "amplitude"): UNITLESS,
9471 ("solver", "verification", "sources", "scalar", "slope_x"): (WAVENUMBER, "cli"),
9472 ("solver", "verification", "sources", "scalar", "kx"): (WAVENUMBER, "cli"),
9473 ("solver", "verification", "sources", "scalar", "ky"): (WAVENUMBER, "cli"),
9474 ("solver", "verification", "sources", "scalar", "kz"): (WAVENUMBER, "cli"),
9475 ("solver", "scalar_transport"): UNITLESS,
9476 # ----- monitor.yml -----
9477 ("monitor", "logging"): NOT_A_QUANTITY,
9478 ("monitor", "profiling"): NOT_A_QUANTITY,
9479 ("monitor", "diagnostics"): NOT_A_QUANTITY,
9480 ("monitor", "io"): NOT_A_QUANTITY,
9481 ("monitor", "solver_monitoring"): NOT_A_QUANTITY,
9482 ("monitor", "solution_monitoring"): NOT_A_QUANTITY,
9483 ("monitor", "field_statistics", "enabled"): NOT_A_QUANTITY,
9484 ("monitor", "field_statistics", "windows", "[]", "name"): NOT_A_QUANTITY,
9485 ("monitor", "field_statistics", "windows", "[]", "weighting"): NOT_A_QUANTITY,
9486 ("monitor", "field_statistics", "windows", "[]", "step_cadence"): NOT_A_QUANTITY,
9487 ("monitor", "field_statistics", "windows", "[]", "start_time"): (TIME, "cli"),
9488 ("monitor", "field_statistics", "windows", "[]", "end_time"): (TIME, "cli"),
9489 ("monitor", "field_statistics", "windows", "[]", "time_cadence"): (TIME, "cli"),
9490 ("monitor", "field_statistics", "windows", "[]", "fields"): NOT_A_QUANTITY,
9491 ("monitor", "field_statistics", "windows", "[]", "covariances"): NOT_A_QUANTITY,
9492 # ----- post.yml: steps, indices, selectors, and names only -----
9493 ("post",): NOT_A_QUANTITY,
9494}
9495
9496#: Boundary-condition handler parameters (the free-form `params` mapping of a face).
9497BC_PARAM_QUANTITIES = {
9498 "vx": (VELOCITY, "cli"),
9499 "vy": (VELOCITY, "cli"),
9500 "vz": (VELOCITY, "cli"),
9501 "v_max": (VELOCITY, "cli"),
9502 "target_flux": (VOLUME_FLUX, "cli"),
9503 "enforce_seam_flux": NOT_A_QUANTITY,
9504 "apply_trim": NOT_A_QUANTITY,
9505 # Every profile source stages a velocity PICSLICE: a file or generated profile is
9506 # physical, and a field_slice declares the scales it was written in.
9507 "source": (VELOCITY, "staging"),
9508}
9509
9510#: Initial-condition generator parameters (`properties.initial_conditions.params`), by
9511#: generator. Nested mappings use dotted names.
9512IC_PARAM_QUANTITIES = {
9513 "zero": {},
9514 "constant": {
9515 "u_physical": (VELOCITY, "cli"), "v_physical": (VELOCITY, "cli"),
9516 "w_physical": (VELOCITY, "cli"), "velocity_physical": (VELOCITY, "cli"),
9517 "flow_direction": NOT_A_QUANTITY,
9518 },
9519 "streamwise_constant": {
9520 "velocity_physical": (VELOCITY, "cli"), "flow_direction": NOT_A_QUANTITY,
9521 },
9522 "poiseuille": {
9523 "peak_velocity_physical": (VELOCITY, "cli"), "flow_direction": NOT_A_QUANTITY,
9524 },
9525 "ic_gen": {
9526 "field": NOT_A_QUANTITY, "script": NOT_A_QUANTITY, "cli_args": NOT_A_QUANTITY,
9527 # Expressions are written in physical coordinates and physical values.
9528 "config_file": (None, "provider"),
9529 },
9530 "spectral_random_velocity": {
9531 "field": NOT_A_QUANTITY, "seed": NOT_A_QUANTITY, "remove_mean": NOT_A_QUANTITY,
9532 "random.distribution": NOT_A_QUANTITY, "random.mean": (VELOCITY, "cli"),
9533 "spectrum.type": NOT_A_QUANTITY,
9534 "spectrum.k0": (WAVENUMBER, "cli"), "spectrum.k_cut": (WAVENUMBER, "cli"),
9535 "projection.type": NOT_A_QUANTITY, "projection.operator": NOT_A_QUANTITY,
9536 "normalization.type": NOT_A_QUANTITY, "normalization.target": (VELOCITY, "cli"),
9537 },
9538}
9539IC_PARAM_QUANTITIES["resampled_velocity"] = {
9540 "field": NOT_A_QUANTITY, "format": NOT_A_QUANTITY, "cells": NOT_A_QUANTITY,
9541 "sample_offset": NOT_A_QUANTITY, "projection": NOT_A_QUANTITY, "remove_mean": NOT_A_QUANTITY,
9542 # The source file holds physical velocities; the provider divides them by velocity_ref.
9543 "source_file": (VELOCITY, "provider"),
9544 "box_length": (LENGTH, "cli"),
9545 "layout.fastest_axis": NOT_A_QUANTITY, "layout.components": NOT_A_QUANTITY,
9546 "layout.dtype": NOT_A_QUANTITY, "layout.byte_order": NOT_A_QUANTITY,
9547 "layout.header_bytes": NOT_A_QUANTITY, "layout.datasets": NOT_A_QUANTITY,
9548 "filter.type": NOT_A_QUANTITY, "filter.cutoff": (WAVENUMBER, "cli"), "filter.width": (LENGTH, "cli"),
9549}
9550IC_PARAM_QUANTITIES["channel_spectral_velocity"] = IC_PARAM_QUANTITIES["duct_spectral_velocity"] = {
9551 "field": NOT_A_QUANTITY, "seed": NOT_A_QUANTITY, "streamwise_axis": NOT_A_QUANTITY,
9552 "wall_axes": NOT_A_QUANTITY, "wall_modes": NOT_A_QUANTITY, "initial_spectra": NOT_A_QUANTITY,
9553 "bulk_velocity": (VELOCITY, "cli"), "perturbation_rms": (VELOCITY, "cli"),
9554 "spectrum.type": NOT_A_QUANTITY,
9555 "spectrum.k0": (WAVENUMBER, "cli"), "spectrum.k_cut": (WAVENUMBER, "cli"),
9556}
9557IC_PARAM_QUANTITIES["stability_mode_velocity"] = {
9558 "field": NOT_A_QUANTITY, "streamwise_axis": NOT_A_QUANTITY, "cross_axis": NOT_A_QUANTITY,
9559 "equation": NOT_A_QUANTITY, "eigen_points": NOT_A_QUANTITY, "initial_spectra": NOT_A_QUANTITY,
9560 # The expression is physical in both coordinate and value; the provider scales both.
9561 "base_profile": (VELOCITY, "provider"),
9562 "velocity_difference": (VELOCITY, "cli"),
9563 # Mode and noise amplitudes are fractions of the velocity difference; phases are radians.
9564 "modes": NOT_A_QUANTITY,
9565 "noise.rms": NOT_A_QUANTITY, "noise.seed": NOT_A_QUANTITY, "noise.wall_modes": NOT_A_QUANTITY,
9566 "noise.planar": NOT_A_QUANTITY, "noise.spectrum.type": NOT_A_QUANTITY,
9567 "noise.spectrum.k0": (WAVENUMBER, "cli"), "noise.spectrum.k_cut": (WAVENUMBER, "cli"),
9568}
9569
9570
9571def input_quantity(path: tuple) -> tuple:
9572 """!
9573 @brief Return the `(dimension, conversion site)` recorded for one configuration input.
9574 @param[in] path Role followed by the schema path, e.g. `("case", "run_control", "dt_physical")`.
9575 @return The entry for the path, or for its nearest covering ancestor.
9576 @throws KeyError when no entry covers the path.
9577 """
9578 for length in range(len(path), 0, -1):
9579 entry = INPUT_QUANTITIES.get(tuple(path[:length]))
9580 if entry is not None:
9581 return entry
9582 raise KeyError(f"No physical dimension is recorded for input {'.'.join(map(str, path))}.")
9583
9584
9585def reference_scale(dimension: tuple, scales: dict) -> float:
9586 """!
9587 @brief Return the factor that turns a physical quantity of this dimension into solver units.
9588 @param[in] dimension `(length, velocity, density)` exponents.
9589 @param[in] scales Mapping with `length_ref` and `velocity_ref`, and `density` when used.
9590 @return `length_ref**a * velocity_ref**b * density**c`.
9591 """
9592 length, velocity, density = dimension
9593 factor = float(scales["length_ref"]) ** length * float(scales["velocity_ref"]) ** velocity
9594 if density:
9595 factor *= float(scales["density"]) ** density
9596 return factor
9597
9598
9599def input_reference_scale(path: tuple, scales: dict) -> float:
9600 """!
9601 @brief Return the reference scale of one recorded input, for converting a whole payload.
9602 @details File payloads (grids, inlet profiles) are converted element by element by
9603 their own readers; this gives them the one factor their recorded dimension
9604 implies, so they do not restate the dimension.
9605 @param[in] path Role and schema path of the input.
9606 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9607 @return The reference scale of the input's recorded dimension.
9608 """
9609 dimension, _site = input_quantity(path)
9610 if dimension is None:
9611 raise ValueError(f"Input {'.'.join(map(str, path[1:]))} has no fixed dimension.")
9612 return reference_scale(dimension, scales)
9613
9614
9615def quantity_to_solver_units(value, quantity: tuple, scales: dict, label: str):
9616 """!
9617 @brief Convert a physical value, or a list of them, by one recorded quantity entry.
9618 @param[in] value Physical value as written by the user.
9619 @param[in] quantity `(dimension, conversion site)` entry from a dimension table.
9620 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9621 @param[in] label Configuration path, for diagnostics.
9622 @return The value divided by its dimension's reference scale.
9623 @throws ValueError when the entry records no fixed dimension.
9624 """
9625 dimension, _site = quantity
9626 if dimension is None:
9627 raise ValueError(f"Input {label} has no fixed dimension to convert.")
9628 factor = reference_scale(dimension, scales)
9629 if isinstance(value, (list, tuple)):
9630 return [_to_finite_float(item, label) / factor for item in value]
9631 return _to_finite_float(value, label) / factor
9632
9633
9634def to_solver_units(value, path: tuple, scales: dict):
9635 """!
9636 @brief Convert one physical configuration value, or a list of them, to solver units.
9637 @param[in] value Physical value as written by the user.
9638 @param[in] path Role and schema path whose recorded dimension governs the conversion.
9639 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9640 @return The value divided by its dimension's reference scale.
9641 @throws ValueError when the path records no fixed dimension.
9642 """
9643 return quantity_to_solver_units(value, input_quantity(path), scales,
9644 ".".join(str(part) for part in path[1:]))
9645
9646
9647def resolve_reference_scales(case_cfg: dict) -> dict:
9648 """!
9649 @brief Resolve the reference length and velocity (and density, when given) of a case.
9650 @details This is the part of the scaling contract every conversion needs. It does not
9651 require the fluid block, so boundary-condition preparation can use it before
9652 the full fluid contract is validated.
9653 @param[in] case_cfg Parsed case configuration.
9654 @return Mapping with `length_ref`, `velocity_ref`, and optionally `density`.
9655 @throws ValueError when a reference scale is missing, non-finite, or not positive.
9656 """
9657 scaling = ((case_cfg.get("properties") or {}).get("scaling") or {})
9658 length_ref = _to_finite_float(scaling.get("length_ref"), "properties.scaling.length_ref")
9659 velocity_ref = _to_finite_float(scaling.get("velocity_ref"), "properties.scaling.velocity_ref")
9660 if length_ref <= 0.0 or velocity_ref <= 0.0:
9661 raise ValueError("properties.scaling.length_ref and velocity_ref must be positive.")
9662 scales = {"length_ref": length_ref, "velocity_ref": velocity_ref}
9663 density = ((case_cfg.get("properties") or {}).get("fluid") or {}).get("density")
9664 if density is not None:
9665 scales["density"] = _to_finite_float(density, "properties.fluid.density")
9666 return scales
9667
9668
9669RUN_OWNED_DIRECTORY_KEYS = ("log", "output")
9670UNSAFE_PATHS_OVERRIDE_KEY = "allow_unsafe_paths"
9671
9672# Directory names the run tree already owns. A run-owned directory must not collide
9673# with one of these: the log directory in particular is recursively deleted at the
9674# start of a fresh solve, so pointing it at `config` would destroy the run's own
9675# provenance before the solver had produced anything.
9676RESERVED_RUN_DIRECTORY_NAMES = ("config", "scheduler", "checkpoints", "visualization")
9677
9678# Defaults the runtime uses when a run-owned directory is not configured. An omitted
9679# key is not absent at runtime, so collision checks must resolve these first.
9680RUN_DIRECTORY_DEFAULTS = {"log": "logs", "output": "output"}
9681
9682# Runtime flags that select run-owned directories. The workspace contract owns them;
9683# raw passthrough must not replace the canonical values emitted by the generator.
9684RESERVED_DIRECTORY_FLAGS = (
9685 "-log_dir", "-output_dir", "-restart_dir", "-analysis_dir",
9686 "-allow_unsafe_log_dir",
9687)
9688
9689# PETSc indirection that could reintroduce a reserved flag without naming it. An
9690# options file or an alias is evaluated by PETSc itself, so its contents are outside
9691# every check this module performs.
9692RESERVED_INDIRECTION_FLAGS = ("-options_file", "-options_file_yaml", "-alias")
9693
9694
9695# Characters that cannot survive a PETSc options line unambiguously. A run
9696# subdirectory name containing whitespace, quotes, or a comment marker either needs
9697# quoting the generator does not apply, or changes how PETSc tokenizes the line.
9698# Rejecting them keeps the generated control file unambiguous by construction.
9699UNSAFE_DIRECTORY_CHARACTERS = ('"', "'", "#", "\n", "\r", "\t")
9700
9701
9702def directory_value_charset_problem(value: str) -> str:
9703 """!
9704 @brief Describe why a directory value cannot be written to a PETSc options line.
9705 @param[in] value Configured directory value.
9706 @return Human-readable reason, or an empty string when the value is safe.
9707 """
9708 if any(character.isspace() for character in value):
9709 return "contains whitespace"
9710 for character in UNSAFE_DIRECTORY_CHARACTERS:
9711 if character in value:
9712 label = {'"': "a double quote", "'": "a single quote", "#": "a comment marker"}.get(
9713 character, "a control character"
9714 )
9715 return f"contains {label}"
9716 return ""
9717
9718
9720 """!
9721 @brief Classify a configured run directory value.
9722
9723 @details Containment is judged lexically against the run directory, because the run
9724 directory does not exist yet at validation time. Beyond escaping, a value
9725 is rejected when it resolves to the run root itself: the log directory is
9726 recursively deleted on a fresh solve, so `.` or `a/..` would delete the run.
9727 @param[in] value Configured directory value from monitor `io.directories`.
9728 @return One of "contained", "escaping", "tilde", "run_root", or "invalid".
9729 """
9730 if not isinstance(value, str) or not value.strip():
9731 return "invalid"
9732 candidate = value.strip()
9733 # `~` is its own verdict because nothing expands it anywhere in the pipeline. The
9734 # control file is read by PETSc, not by a shell, and both the planner and the C
9735 # runtime treat a value not starting with `/` as relative to the run - so `~/logs`
9736 # names a literal `~` directory inside the run tree.
9737 #
9738 # It was worse before this verdict existed: physical containment classified `~` as
9739 # an authorizable external absolute location by expanding it, while every layer
9740 # that actually used the value treated it literally. The two disagreed about which
9741 # directory was being deleted. The fix is to refuse the value, not to expand it -
9742 # expanding it in one layer would only move the disagreement.
9743 if candidate.startswith("~"):
9744 return "tilde"
9745 if os.path.isabs(candidate):
9746 return "escaping"
9747 normalized = os.path.normpath(candidate)
9748 if normalized == os.pardir or normalized.startswith(os.pardir + os.sep):
9749 return "escaping"
9750 if normalized in (".", ""):
9751 return "run_root"
9752 return "contained"
9753
9754
9755def normalized_run_directory(value: str) -> str:
9756 """!
9757 @brief Normalized, comparable form of a contained run directory value.
9758 @param[in] value Configured directory value.
9759 @return Normalized relative path.
9760 """
9761 return os.path.normpath(str(value).strip()).replace(os.sep, "/")
9762
9763
9764def paths_overlap(first: str, second: str) -> bool:
9765 """!
9766 @brief Whether two run-relative directories are the same or nested in one another.
9767 @param[in] first Normalized directory.
9768 @param[in] second Normalized directory.
9769 @return True when one contains the other.
9770 """
9771 if first == second:
9772 return True
9773 return first.startswith(second + "/") or second.startswith(first + "/")
9774
9775
9776def resolve_unsafe_paths_override(dirs: dict, monitor_path: str) -> tuple:
9777 """!
9778 @brief Resolve the unsafe-paths override, requiring a real YAML boolean.
9779
9780 @details A truthy string such as "false" must never enable an override that permits
9781 a destructive path. Only a genuine boolean `true` enables it; any other
9782 type is a configuration error rather than a silent interpretation.
9783 @param[in] dirs The `io.directories` mapping.
9784 @param[in] monitor_path Path to the monitor file, for error messages.
9785 @return Tuple of (enabled, errors).
9786 """
9787 if UNSAFE_PATHS_OVERRIDE_KEY not in dirs:
9788 return False, []
9789 raw = dirs[UNSAFE_PATHS_OVERRIDE_KEY]
9790 if raw is True:
9791 return True, []
9792 if raw is False:
9793 return False, []
9794 return False, [
9795 f" {monitor_path}: 'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}' must be a YAML boolean "
9796 f"(true or false), got {raw!r}. Quoted strings and numbers are rejected so a value like "
9797 f"\"false\" cannot silently enable an unsafe path."
9798 ]
9799
9800
9801def evaluate_run_directories(values: dict, override: bool, explicit: set = None) -> tuple:
9802 """!
9803 @brief Apply every run-directory safety rule to a set of effective directory values.
9804
9805 @details Single source of truth for the rules, shared by configuration validation and
9806 submission preflight so the two cannot drift apart. Callers must pass
9807 *effective* values with defaults filled in.
9808
9809 Two classes of finding are distinguished. **Waivable** findings concern a
9810 deliberate external location; `allow_unsafe_paths` downgrades those to
9811 warnings. **Non-waivable** findings concern self-destruction or a value that
9812 cannot be written unambiguously - the run root itself, a reserved run
9813 directory, log/output overlap, and malformed characters. The override was
9814 granted for deliberate external storage, never for deleting the run's own
9815 config or emitting an ambiguous option line, so those stay errors.
9816 @param[in] values Effective mapping of directory key to configured value.
9817 @param[in] override Whether the unsafe-paths override is enabled.
9818 @param[in] explicit Keys the user actually configured; the rest are reported as defaults.
9819 @return Tuple of (errors, warnings) as bare messages without a file prefix.
9820 """
9821 errors: list = []
9822 warnings: list = []
9823 configured_keys = set(values) if explicit is None else set(explicit)
9824
9825 def waivable(message: str) -> None:
9826 """!
9827 @brief Record a finding the unsafe-paths override may downgrade.
9828 @param[in] message Finding text.
9829 @return None.
9830 """
9831 if override:
9832 warnings.append(
9833 f"{message} Allowed only because '{UNSAFE_PATHS_OVERRIDE_KEY}: true' is set."
9834 )
9835 else:
9836 errors.append(
9837 f"{message} Use a directory inside the run tree, or set "
9838 f"'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}: true' to override deliberately."
9839 )
9840
9841 def fatal(message: str) -> None:
9842 """!
9843 @brief Record a finding the override must never waive.
9844 @param[in] message Finding text.
9845 @return None.
9846 """
9847 errors.append(f"{message} This cannot be overridden.")
9848
9849 destructive_note = (
9850 "On a fresh solve the runtime RECURSIVELY DELETES this directory before writing to it."
9851 )
9852 resolved: dict = {}
9853 for key in RUN_OWNED_DIRECTORY_KEYS:
9854 if key not in values:
9855 continue
9856 raw = values[key]
9857 detail = destructive_note if key == "log" else (
9858 "Run output must stay inside the run directory so it can be archived and restored."
9859 )
9860 if not isinstance(raw, str) or not raw.strip():
9861 fatal(f"'io.directories.{key}' must be a non-empty relative path (got {raw!r}).")
9862 continue
9863
9864 # Both axes are reported. Escaping is the safety-critical property and leads,
9865 # but character problems are non-waivable, so a value that is both must not be
9866 # able to slip through by overriding only the escape.
9867 verdict = classify_run_directory_value(raw)
9868 charset_problem = directory_value_charset_problem(raw)
9869 if charset_problem:
9870 fatal(
9871 f"'io.directories.{key}' = {raw!r} {charset_problem}. Run directory names must be "
9872 f"writable to a PETSc options line without quoting; use a plain relative path such "
9873 f"as 'logs' or 'diagnostics/run1'."
9874 )
9875 if verdict == "tilde":
9876 fatal(
9877 f"'io.directories.{key}' = {raw!r} starts with '~', which nothing expands. "
9878 f"The control file is read by PETSc rather than by a shell, and the C "
9879 f"runtime resolves a value not starting with '/' relative to the run - so "
9880 f"this would be planned as one directory and deleted as another. Give a "
9881 f"real absolute path if an external location is intended. This cannot be "
9882 f"overridden."
9883 )
9884 continue
9885 if verdict == "escaping":
9886 if not (isinstance(raw, str) and raw.strip().startswith("/")):
9887 fatal(
9888 f"'io.directories.{key}' = {raw!r} escapes the run directory by relative "
9889 f"traversal. {detail} A relative escape lands among sibling runs and study "
9890 f"members, so it is never authorizable; give an absolute path if an external "
9891 f"location is genuinely intended."
9892 )
9893 continue
9894 # No `continue`: an authorized absolute path must still face the
9895 # non-waivable checks below, or it could target a run-owned directory such
9896 # as `/abs/run/config`.
9897 waivable(f"'io.directories.{key}' = {raw!r} escapes the run directory. {detail}")
9898 if verdict == "run_root":
9899 fatal(
9900 f"'io.directories.{key}' = {raw!r} resolves to the run directory itself. "
9901 + (destructive_note + " That would delete the entire run."
9902 if key == "log" else "Run output must live in its own subdirectory.")
9903 )
9904 continue
9905 if charset_problem:
9906 continue
9907 normalized = normalized_run_directory(raw)
9908 resolved[key] = normalized
9909 # Every segment is checked, not just the first: `./config`, `output/sub`, and
9910 # `/abs/run/config` all target run-owned directories.
9911 segments = [s for s in normalized.split("/") if s not in ("", ".")]
9912 hit = next((s for s in segments if s in RESERVED_RUN_DIRECTORY_NAMES), None)
9913 if hit:
9914 fatal(
9915 f"'io.directories.{key}' = {raw!r} targets the reserved run directory "
9916 f"'{hit}'. " + (destructive_note if key == "log"
9917 else "That directory is owned by the run tree.")
9918 )
9919
9920 if "log" in resolved and "output" in resolved and paths_overlap(resolved["log"], resolved["output"]):
9921 log_source = "" if "log" in configured_keys else " (default)"
9922 out_source = "" if "output" in configured_keys else " (default)"
9923 fatal(
9924 f"'io.directories.log' ({resolved['log']!r}{log_source}) and 'io.directories.output' "
9925 f"({resolved['output']!r}{out_source}) overlap. {destructive_note} "
9926 f"That would delete solver output."
9927 )
9928 return errors, warnings
9929
9930
9931# Typed physical verdicts. Waivability is decided on the verdict, never on a substring
9932# of the message: a finding reading "resolves to the run directory itself" contains no
9933# word like "escapes", so message matching silently downgraded self-destruction to a
9934# warning. Message text is for humans; these constants are for the rule.
9935PHYSICAL_VERDICT_CONTAINED = "contained"
9936PHYSICAL_VERDICT_RUN_ROOT = "run_root"
9937PHYSICAL_VERDICT_ANCESTOR = "ancestor"
9938PHYSICAL_VERDICT_RELATIVE_ESCAPE = "relative_escape"
9939PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE = "external_absolute"
9940
9941# The only verdict an explicit authorization may waive. An external absolute location
9942# is a deliberate, if dangerous, choice. Deleting the run itself, or an ancestor of it,
9943# or following a relative symlink out of the tree, are not choices anyone makes on
9944# purpose, so no authorization reaches them.
9945WAIVABLE_PHYSICAL_VERDICTS = frozenset({PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE})
9946
9947
9948def classify_physical_containment(run_dir: str, values: dict) -> list:
9949 """!
9950 @brief Classify where each run-owned directory physically lands.
9951
9952 @details Lexical containment is not enough: a contained name can be a symlink to an
9953 external directory, and `PetscRMTree` follows symlinks. This resolves the
9954 real path - including any symlinked ancestor - and reports a typed verdict
9955 so the caller can apply the waiver rule structurally.
9956 @param[in] run_dir Run directory the values are relative to.
9957 @param[in] values Effective directory mapping.
9958 @return List of (key, verdict, message) for every value that is not contained.
9959 """
9960 findings: list = []
9961 try:
9962 root = os.path.realpath(run_dir)
9963 except OSError:
9964 return findings
9965 for key in RUN_OWNED_DIRECTORY_KEYS:
9966 raw = values.get(key)
9967 if not isinstance(raw, str) or not raw.strip():
9968 continue
9969 text = raw.strip()
9970 # Whether the *value* was given as an absolute location, which is the only form
9971 # an authorization can cover. A relative name that resolves outside the tree got
9972 # there through a symlink, and that is never what an operator asked for.
9973 # Only a real absolute path can be waived. `~` is refused before this point by
9974 # the lexical rules, and is not expanded here either: an earlier version did
9975 # expand it, which made this layer reason about a home directory no other layer
9976 # ever resolves.
9977 absolute = text.startswith("/")
9978 candidate = os.path.join(run_dir, text)
9979 real = os.path.realpath(candidate)
9980
9981 if real == root:
9982 findings.append((key, PHYSICAL_VERDICT_RUN_ROOT,
9983 f"'io.directories.{key}' = {raw!r} resolves to the run directory itself "
9984 f"({real!r}); deleting it would destroy the run. This cannot be overridden."))
9985 elif real == os.sep or root.startswith(real.rstrip(os.sep) + os.sep):
9986 findings.append((key, PHYSICAL_VERDICT_ANCESTOR,
9987 f"'io.directories.{key}' = {raw!r} resolves to {real!r}, which CONTAINS the run "
9988 f"directory {root!r}. The runtime deletes this path recursively, so it would "
9989 f"destroy the run and everything beside it. This cannot be overridden."))
9990 elif real.startswith(root + os.sep):
9991 continue
9992 elif absolute:
9993 findings.append((key, PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE,
9994 f"'io.directories.{key}' = {raw!r} resolves to {real!r}, which is outside the run "
9995 f"directory {root!r}. The runtime deletes its log directory recursively."))
9996 else:
9997 findings.append((key, PHYSICAL_VERDICT_RELATIVE_ESCAPE,
9998 f"'io.directories.{key}' = {raw!r} is a relative name that resolves to {real!r}, "
9999 f"outside the run directory {root!r} - a symlink leads out of the tree. A "
10000 f"relative escape is never authorizable; name an absolute path if an external "
10001 f"location is genuinely intended. This cannot be overridden."))
10002 return findings
10003
10004
10005def check_physical_containment(run_dir: str, values: dict) -> list:
10006 """!
10007 @brief Human-readable physical containment violations.
10008 @param[in] run_dir Run directory the values are relative to.
10009 @param[in] values Effective directory mapping.
10010 @return Violation lines.
10011 """
10012 return [message for _, _, message in classify_physical_containment(run_dir, values)]
10013
10014
10015def effective_run_directories(configured: dict) -> dict:
10016 """!
10017 @brief Fill in defaults for run-owned directories that were not configured.
10018
10019 @details An omitted key is not absent at runtime, it takes its default. Checking
10020 only explicit keys would miss `log: output`, which collides with the
10021 default output directory and would delete solver output.
10022 @param[in] configured Configured directory mapping, possibly partial.
10023 @return Effective mapping with defaults applied.
10024 """
10025 effective = dict(RUN_DIRECTORY_DEFAULTS)
10026 for key in RUN_OWNED_DIRECTORY_KEYS:
10027 if key in configured:
10028 effective[key] = configured[key]
10029 return effective
10030
10031
10032def validate_run_directory_containment(monitor_cfg: dict, monitor_path: str) -> tuple:
10033 """!
10034 @brief Classify legacy directory values as defense-in-depth during validation.
10035 @details The monitor schema rejects this removed surface. Keeping the stricter
10036 classifier here ensures malformed or manually constructed configurations
10037 still receive the safety findings that protect recursive log cleanup.
10038 @param[in] monitor_cfg Parsed monitor YAML dictionary.
10039 @param[in] monitor_path Path to the monitor file, for error messages.
10040 @return Tuple of (errors, warnings).
10041 """
10042 io_cfg = (monitor_cfg or {}).get("io") or {}
10043 dirs = io_cfg.get("directories")
10044 if not isinstance(dirs, dict):
10045 return [], []
10046 override, override_errors = resolve_unsafe_paths_override(dirs, monitor_path)
10047 errors, warnings = evaluate_run_directories(
10048 effective_run_directories(dirs), override, explicit=set(dirs)
10049 )
10050 return (
10051 override_errors + [f" {monitor_path}: {message}" for message in errors],
10052 [f" {monitor_path}: {message}" for message in warnings],
10053 )
10054
10055
10056def validate_reserved_directory_flags(config: dict, config_path: str, label: str) -> list:
10057 """!
10058 @brief Reject raw PETSc passthrough options that set run-owned directories.
10059
10060 @details Passthrough surfaces emit `{flag: value}` verbatim into the generated
10061 control file. Run-owned path flags are reserved for the fixed workspace
10062 topology and may only be emitted by the generator.
10063 @param[in] config Parsed configuration mapping to scan.
10064 @param[in] config_path Path to the file, for error messages.
10065 @param[in] label Human-readable description of the surface being scanned.
10066 @return Violation lines.
10067 """
10068 violations: list = []
10069
10070 def scan(node, trail: str) -> None:
10071 """!
10072 @brief Walk the mapping looking for reserved flags used as keys.
10073 @param[in] node Current mapping, list, or scalar node.
10074 @param[in] trail Dotted path to the current node, for error messages.
10075 @return None.
10076 """
10077 if isinstance(node, dict):
10078 for key, value in node.items():
10079 token = key.strip() if isinstance(key, str) else key
10080 if token in RESERVED_DIRECTORY_FLAGS:
10081 violations.append(
10082 f" {config_path}: {label} sets the reserved flag '{token}' at "
10083 f"{trail or '<root>'}. Run directories are fixed by the workspace "
10084 "contract; raw passthrough cannot override them."
10085 )
10086 elif token in RESERVED_INDIRECTION_FLAGS:
10087 violations.append(
10088 f" {config_path}: {label} sets '{token}' at {trail or '<root>'}. PETSc "
10089 f"evaluates that indirection itself, so its contents cannot be checked "
10090 "here and could reintroduce a run-directory flag. Remove the indirection."
10091 )
10092 scan(value, f"{trail}.{key}" if trail else str(key))
10093 elif isinstance(node, list):
10094 for index, item in enumerate(node):
10095 scan(item, f"{trail}[{index}]")
10096
10097 scan(config, "")
10098 return violations
10099
10100
10101def physical_units_transition_notices(case_cfg: dict, solver_cfg: dict, monitor_cfg: dict) -> list:
10102 """!
10103 @brief Name the inputs whose meaning changed when every input became physical.
10104 @details These inputs were once read in solver units. At unit reference scales the
10105 two readings coincide, so a notice is only needed when the scales differ
10106 from one; it says which values are now divided by a reference scale.
10107 @param[in] case_cfg Parsed case configuration.
10108 @param[in] solver_cfg Parsed solver configuration.
10109 @param[in] monitor_cfg Parsed monitor configuration.
10110 @return Warning lines, empty when the case uses unit reference scales.
10111 """
10112 try:
10113 scales = resolve_reference_scales(case_cfg)
10114 except (ValueError, TypeError, AttributeError):
10115 return []
10116 if scales["length_ref"] == 1.0 and scales["velocity_ref"] == 1.0:
10117 return []
10118 physics = ((case_cfg.get("models") or {}).get("physics") or {})
10119 ic = ((case_cfg.get("properties") or {}).get("initial_conditions") or {})
10120 operation = (solver_cfg or {}).get("operation_mode") or {}
10121 windows = (((monitor_cfg or {}).get("field_statistics") or {}).get("windows") or [])
10122 changed = []
10123 if isinstance((physics.get("particles") or {}).get("point_source"), dict):
10124 changed.append("models.physics.particles.point_source")
10125 wall_cfg = (physics.get("turbulence") or {}).get("wall_function")
10126 if isinstance(wall_cfg, dict) and "roughness_height" in wall_cfg:
10127 changed.append("models.physics.turbulence.wall_function.roughness_height")
10128 if str(ic.get("mode", "")).strip().lower() == "file":
10129 changed.append("properties.initial_conditions (mode: file payload)")
10130 if str(ic.get("generator", "")).strip().lower().replace("-", "_") in _PYTHON_INITIAL_CONDITION_PROVIDERS:
10131 changed.append(f"properties.initial_conditions.params ({ic.get('generator')})")
10132 if isinstance(operation, dict) and operation.get("uniform_flow"):
10133 changed.append("operation_mode.uniform_flow")
10134 if ((solver_cfg or {}).get("verification") or {}).get("sources"):
10135 changed.append("verification.sources")
10136 if any(isinstance(window, dict) and ({"start_time", "end_time", "time_cadence"} & set(window))
10137 for window in windows):
10138 changed.append("field_statistics.windows[].{start_time,end_time,time_cadence}")
10139 if not changed:
10140 return []
10141 return [
10142 f"Physical units: with length_ref={scales['length_ref']:g} and "
10143 f"velocity_ref={scales['velocity_ref']:g}, these inputs are read as physical values and "
10144 f"converted to solver units: {', '.join(changed)}. Inputs written in solver units "
10145 f"before this rule must be converted; see docs/pages/19_Nondimensionalization.md."
10146 ]
10147
10148
10149def validate_simulation_configs(case_cfg: dict, solver_cfg: dict, monitor_cfg: dict,
10150 case_path: str, solver_path: str, monitor_path: str,
10151 restart_source_given: bool = False):
10152 """!
10153 @brief Validates every configuration a simulation run consumes, before any work is done.
10154 @details Covers the three roles the solver is launched with: the case, the solver,
10155 and the monitor. Only one of those is a solver configuration, which is
10156 why this is not named for the solver; the monitor in particular carries
10157 observation and field-statistics contracts that have nothing to do with
10158 the numerical scheme.
10159
10160 Checks required sections, required keys, physical sanity, and the
10161 cross-file combinations no single file can rule out. Post-processing
10162 configuration is validated separately by `validate_post_config()`.
10163 @param[in] case_cfg Parsed case YAML dictionary.
10164 @param[in] solver_cfg Parsed solver YAML dictionary.
10165 @param[in] monitor_cfg Parsed monitor YAML dictionary.
10166 @param[in] case_path Path to case file (for error messages).
10167 @param[in] solver_path Path to solver file (for error messages).
10168 @param[in] monitor_path Path to monitor file (for error messages).
10169 @param[in] restart_source_given Whether the invocation names a restart source
10170 (--restart-from or --continue), which silences the
10171 reminder that the case needs one.
10172 @throws SystemExit on validation failure.
10173 """
10174 errors = []
10175 warnings = []
10176 warnings.extend(physical_units_transition_notices(case_cfg, solver_cfg, monitor_cfg))
10178 case_cfg, case_path, "case solver_parameters / passthrough"))
10180 solver_cfg, solver_path, "solver petsc_passthrough_options"))
10181 eulerian_source_mode = "solve"
10182
10183 legacy_models = case_cfg.get("models") if isinstance(case_cfg, dict) else None
10184 legacy_statistics = legacy_models.get("statistics") if isinstance(legacy_models, dict) else None
10185 if legacy_statistics is not None:
10186 errors.append(
10187 f" {case_path}: 'models.statistics' was removed with the legacy averaging system. "
10188 "Use instantaneous output and offline postprocessing until the replacement "
10189 "field-statistics pipeline is available."
10190 )
10191 if isinstance(solver_cfg, dict) and "solution_convergence" in solver_cfg:
10192 errors.append(
10193 f" {solver_path}: 'solution_convergence' moved to "
10194 "monitor.yml -> solution_monitoring.convergence."
10195 )
10196 if errors:
10198
10199 _validate_yaml_schema_keys(case_cfg, _CASE_SCHEMA, case_path, errors)
10200 _validate_yaml_schema_keys(solver_cfg, _SOLVER_SCHEMA, solver_path, errors)
10201 _validate_yaml_schema_keys(monitor_cfg, _MONITOR_SCHEMA, monitor_path, errors)
10202
10203 # --- case.yml: required top-level sections ---
10204 required_case_sections = ['properties', 'run_control', 'grid', 'models', 'boundary_conditions']
10205 for section in required_case_sections:
10206 if section not in case_cfg:
10207 errors.append(f" {case_path}: missing required section '{section}'.")
10208
10209 if errors:
10211
10212 # --- case.yml: properties sub-keys ---
10213 props = case_cfg.get('properties', {})
10214 for group, keys in [('scaling', ['length_ref', 'velocity_ref']),
10215 ('fluid', ['density', 'viscosity'])]:
10216 sub = props.get(group, {})
10217 if not sub:
10218 errors.append(f" {case_path}: missing 'properties.{group}' section.")
10219 else:
10220 for k in keys:
10221 if k not in sub:
10222 errors.append(f" {case_path}: missing key 'properties.{group}.{k}'.")
10223
10224 # --- case.yml: run_control sub-keys ---
10225 rc = case_cfg.get('run_control', {})
10226 for k in ['start_step', 'total_steps', 'dt_physical']:
10227 if k not in rc:
10228 errors.append(f" {case_path}: missing key 'run_control.{k}'.")
10229
10230 # --- Physical sanity checks ---
10231 # Each value is checked for its type here: later stages convert them with int()/float()
10232 # and would otherwise stop with a Python error instead of naming the key.
10233 for key, value, positive in (
10234 ('properties.fluid.density', props.get('fluid', {}).get('density', 0), True),
10235 ('properties.fluid.viscosity', props.get('fluid', {}).get('viscosity', 0), False),
10236 ('run_control.dt_physical', rc.get('dt_physical', 0), True),
10237 ):
10238 if not isinstance(value, (int, float)) or isinstance(value, bool):
10239 errors.append(f" {case_path}: '{key}' must be a number (got {value!r}).")
10240 elif positive and value <= 0:
10241 errors.append(f" {case_path}: '{key}' must be positive (got {value}).")
10242 elif value < 0:
10243 errors.append(f" {case_path}: '{key}' must be non-negative (got {value}).")
10244 particles_count = (((case_cfg.get('models') or {}).get('physics') or {}).get('particles') or {}).get('count', 0)
10245 for key, value in (('run_control.start_step', rc.get('start_step', 0)),
10246 ('run_control.total_steps', rc.get('total_steps', 0)),
10247 ('models.physics.particles.count', particles_count)):
10248 if not isinstance(value, int) or isinstance(value, bool) or value < 0:
10249 errors.append(f" {case_path}: '{key}' must be a non-negative integer (got {value!r}).")
10250 if errors:
10252
10253 # --- case.yml: grid mode ---
10254 grid_cfg = case_cfg.get('grid', {})
10255 grid_mode = grid_cfg.get('mode')
10256 valid_grid_modes = list(GRID_MODES)
10257 if grid_mode not in valid_grid_modes:
10258 errors.append(f" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
10259 elif grid_mode == 'file':
10260 source_file = grid_cfg.get('source_file')
10261 if not source_file:
10262 errors.append(f" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
10263 else:
10264 try:
10265 source_abs = resolve_workspace_path(case_path, source_file)
10266 except ValueError as exc:
10267 errors.append(f" {case_path}: {exc}")
10268 else:
10269 if not os.path.isfile(source_abs):
10270 errors.append(f" {case_path}: grid.source_file does not exist: {source_abs}")
10271 elif grid_mode == 'programmatic_c':
10272 grid_settings = grid_cfg.get('programmatic_settings')
10273 if not grid_settings:
10274 errors.append(f" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
10275 elif not isinstance(grid_settings, dict):
10276 errors.append(f" {case_path}: 'grid.programmatic_settings' must be a mapping.")
10277 elif grid_mode == 'grid_gen':
10278 gen_cfg = grid_cfg.get('generator')
10279 if not isinstance(gen_cfg, dict):
10280 errors.append(f" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
10281 else:
10282 warn_on_grid_generator_hyphen_keys(gen_cfg, case_path, warnings)
10283 errors.extend(
10284 reject_generator_destination_keys(gen_cfg, case_path, "grid.generator")
10285 )
10286
10287 config_file = gen_cfg.get('config_file')
10288 if not config_file:
10289 errors.append(f" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
10290 else:
10291 try:
10292 config_abs = resolve_workspace_path(case_path, config_file)
10293 except ValueError as exc:
10294 errors.append(f" {case_path}: {exc}")
10295 else:
10296 if not os.path.isfile(config_abs):
10297 errors.append(f" {case_path}: grid.generator.config_file does not exist: {config_abs}")
10298
10299 grid_type = gen_cfg.get('grid_type')
10300 if grid_type is not None and str(grid_type) not in GRID_GENERATOR_TYPES:
10301 errors.append(f" {case_path}: grid.generator.grid_type must be one of "
10302 f"{list(GRID_GENERATOR_TYPES)} (got '{grid_type}').")
10303
10304 # cli_args is an opaque token list handed straight to the generator, so a bad
10305 # geometry selector inside it would otherwise surface as a nonzero subprocess
10306 # exit in the middle of a run rather than as a validation error before one.
10307 errors.extend(validate_grid_generator_cli_args(gen_cfg.get('cli_args'), case_path))
10308
10309 cli_args = gen_cfg.get('cli_args', [])
10310 if cli_args is not None and not isinstance(cli_args, list):
10311 errors.append(f" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
10312 try:
10314 except ValueError as e:
10315 errors.append(f" {case_path}: {e}")
10316
10317 # --- case.yml: boundary_conditions strict validation ---
10318 prepared_blocks = None
10319 try:
10320 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
10321 except ValueError as e:
10322 errors.append(f" {case_path}: {e}")
10323
10324 # --- case.yml: initial_conditions mode-aware validation ---
10325 ic = props.get('initial_conditions', {})
10326 resolved_ic = None
10327 try:
10328 ic_start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
10329 except (TypeError, ValueError):
10330 ic_start_step = 0
10331 try:
10332 ic_eulerian_source = normalize_eulerian_field_source(
10333 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
10334 )
10335 except ValueError:
10336 # The solver-specific validation below reports an invalid source value.
10337 # Keep IC validation independent of that diagnostic.
10338 ic_eulerian_source = "solve"
10339 ic_is_authoritative = ic_eulerian_source == "solve" and ic_start_step == 0
10340 if not ic:
10341 errors.append(f" {case_path}: missing 'properties.initial_conditions' section.")
10342 elif not isinstance(ic, dict):
10343 errors.append(f" {case_path}: 'properties.initial_conditions' must be a mapping.")
10344 elif 'mode' not in ic:
10345 errors.append(
10346 f" {case_path}: missing key 'properties.initial_conditions.mode'. "
10347 "Specify 'generated' or 'file' explicitly."
10348 )
10349 elif ic_is_authoritative:
10350 try:
10351 scaling_contract = resolve_fluid_scaling(case_cfg)
10353 ic, prepared_blocks, scales=scaling_contract,
10354 provider_context={"kinematic_viscosity": scaling_contract["nondimensional_kinematic_viscosity"]},
10355 )
10356 except KeyError as e:
10357 errors.append(f" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
10358 except ValueError as e:
10359 errors.append(f" {case_path}: {e}")
10360 if ic_is_authoritative and resolved_ic and resolved_ic.get("kind") == "stability_mode_velocity":
10361 dimensionality = str((((case_cfg.get("models", {}) or {}).get("physics", {}) or {})
10362 .get("dimensionality", "3D"))).strip().upper()
10363 smv = resolved_ic["params"]
10364 if dimensionality == "2D" and ("i" in (smv["streamwise_axis"], smv["cross_axis"])
10365 or not (smv.get("noise") or {"planar": True})["planar"]):
10366 errors.append(f" {case_path}: stability_mode_velocity in 2D mode needs i as the normal axis "
10367 "(streamwise and cross axes j and k) and noise.planar: true.")
10368 if (ic_is_authoritative and resolved_ic and
10369 GENERATED_IC_PROVIDERS.get(resolved_ic.get("kind"), {}).get("requires_fresh_3d")):
10370 if resolved_ic["kind"] in ("channel_spectral_velocity", "duct_spectral_velocity"):
10371 if (case_cfg.get("models", {}).get("physics", {}).get("fsi", {}) or {}).get("immersed", False):
10372 errors.append(f" {case_path}: wall spectral ICs do not support immersed geometry.")
10373 dimensionality = str((((case_cfg.get("models", {}) or {}).get("physics", {}) or {})
10374 .get("dimensionality", "3D"))).strip().upper()
10375 if dimensionality != "3D":
10376 errors.append(f" {case_path}: {resolved_ic['label']} requires models.physics.dimensionality: 3D.")
10377 if grid_mode == "programmatic_c":
10378 settings = grid_cfg.get("programmatic_settings", {}) or {}
10379 ratios = [settings.get(key, 1.0) for key in ("rxs", "rys", "rzs")]
10380 try:
10381 if any(abs(float(value) - 1.0) > 1.0e-12 for value in ratios):
10382 errors.append(f" {case_path}: {resolved_ic['label']} requires uniform programmatic spacing (rxs/rys/rzs: 1.0).")
10383 except (TypeError, ValueError):
10384 pass
10385 generated_profile = any(
10386 bc.get("handler") == "prescribed_flow"
10387 and ((bc.get("params") or {}).get("source") or {}).get("type") == "generated"
10388 for block in (prepared_blocks or []) for bc in block
10389 )
10390 if grid_mode == 'programmatic_c' and (
10391 generated_profile or (resolved_ic and is_generated_ic_provider(resolved_ic))):
10392 try:
10393 validate_programmatic_bridge_grid_settings(grid_cfg.get('programmatic_settings'))
10394 except ValueError as e:
10395 errors.append(f" {case_path}: {e}")
10396
10397 # --- case.yml: particle initialization validation ---
10398 particles_cfg = case_cfg.get('models', {}).get('physics', {}).get('particles', {})
10399 if particles_cfg and not isinstance(particles_cfg, dict):
10400 errors.append(f" {case_path}: 'models.physics.particles' must be a mapping.")
10401 elif isinstance(particles_cfg, dict):
10402 init_mode_raw = particles_cfg.get('init_mode', 'Surface')
10403 try:
10404 pinit_code = normalize_particle_init_mode(init_mode_raw)
10405 except ValueError as e:
10406 errors.append(f" {case_path}: {e}")
10407 pinit_code = None
10408
10409 # One base seed drives every particle random stream - initial placement, inlet
10410 # re-placement, and Brownian motion - so identical inputs on the same rank count
10411 # reproduce. Omitted, the runtime uses 12345.
10412 random_seed = particles_cfg.get('random_seed')
10413 if random_seed is not None and (isinstance(random_seed, bool) or not isinstance(random_seed, int)
10414 or not 0 <= random_seed <= 2147483647):
10415 errors.append(
10416 f" {case_path}: models.physics.particles.random_seed must be an integer from 0 to "
10417 f"2147483647 (got {random_seed!r})."
10418 )
10419
10420 restart_mode = particles_cfg.get('restart_mode')
10421 if restart_mode is not None and str(restart_mode).lower() not in PARTICLE_RESTART_MODES:
10422 errors.append(
10423 f" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
10424 )
10425 elif 'restart_mode' not in particles_cfg:
10426 try:
10427 start_step = int(rc.get('start_step', 0))
10428 particle_count = int(particles_cfg.get('count', 0) or 0)
10429 except (TypeError, ValueError):
10430 start_step = 0
10431 particle_count = 0
10432 if start_step > 0 and particle_count > 0:
10433 warnings.append(
10434 f"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
10435 "(run_control.start_step > 0, count > 0). C will default to 'load'."
10436 )
10437
10438 if pinit_code == 2:
10439 point_cfg = particles_cfg.get('point_source', {})
10440 if not isinstance(point_cfg, dict):
10441 errors.append(f" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
10442 else:
10443 for coord in ('x', 'y', 'z'):
10444 if coord not in point_cfg:
10445 errors.append(
10446 f" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
10447 )
10448
10449 if 'fields' in particles_cfg:
10450 try:
10451 particle_fields = normalize_particle_fields(particles_cfg['fields'])
10452 except ValueError as e:
10453 errors.append(f" {case_path}: {e}")
10454 particle_fields = []
10455 if particle_fields and int(particles_cfg.get('count', 0) or 0) <= 0:
10456 errors.append(f" {case_path}: models.physics.particles.fields needs particles (count > 0).")
10457 if particle_fields and ((solver_cfg.get('verification') or {}).get('sources') or {}).get('scalar'):
10458 errors.append(
10459 f" {case_path}: models.physics.particles.fields cannot be combined with "
10460 f"{solver_path} verification.sources.scalar, which prescribes Psi at every step.")
10461 if pinit_code == 2:
10462 for entry in particle_fields:
10463 if not any(re.search(r"\b(uniform|normal)\s*\‍(", e) for e in entry["expressions"]):
10464 warnings.append(
10465 f"{case_path}: models.physics.particles.fields.{entry['name']} has no random draw, "
10466 "but init_mode is PointSource: every particle starts at one point and gets the "
10467 "same value. Use a constant, or uniform()/normal() for per-particle values.")
10468 try:
10469 resumes = int(rc.get('start_step', 0)) > 0
10470 except (TypeError, ValueError):
10471 resumes = False
10472 if particle_fields and resumes and str(particles_cfg.get('restart_mode', 'load')).lower() == 'load':
10473 warnings.append(
10474 f"{case_path}: models.physics.particles.fields is ignored when particles are restored "
10475 "(restart_mode: load); their values come from the checkpoint.")
10476
10477 # --- case.yml: domain and physics switches ---
10478 # Multi-block coupling, immersed boundaries and moving bodies are planned, not
10479 # implemented. The switches stay in the schema so that setting one fails here with a
10480 # reason, rather than as an unknown key or, worse, as a run that solves a different
10481 # problem: the solver would accept all three and silently ignore what they promise.
10482 models_cfg = case_cfg.get('models', {}) or {}
10483 domain_cfg = models_cfg.get('domain', {}) or {}
10484 physics_cfg = models_cfg.get('physics', {}) or {}
10485 try:
10486 block_count = int(domain_cfg.get('blocks', 1))
10487 except (TypeError, ValueError):
10488 block_count = None
10489 if block_count != 1:
10490 errors.append(
10491 f" {case_path}: models.domain.blocks must be 1 (got {domain_cfg.get('blocks')!r}). "
10492 "Multi-block domains are planned, not implemented: no solver exchanges data across "
10493 "the faces blocks share, so each block would be solved as an isolated domain."
10494 )
10495 fsi_cfg = physics_cfg.get('fsi', {}) or {}
10496 if not isinstance(fsi_cfg, dict):
10497 errors.append(f" {case_path}: 'models.physics.fsi' must be a mapping.")
10498 else:
10499 for key, feature in (('immersed', 'An immersed boundary'),
10500 ('moving_fsi', 'Moving-body fluid-structure interaction')):
10501 if fsi_cfg.get(key):
10502 errors.append(
10503 f" {case_path}: models.physics.fsi.{key} must be false. {feature} is planned, "
10504 "not implemented: no body geometry is loaded, and the interpolation that would "
10505 "impose one on the flow was never ported."
10506 )
10507 dimensionality = str(physics_cfg.get('dimensionality', '3D')).strip()
10508 if dimensionality not in DOMAIN_DIMENSIONALITY_MODES:
10509 errors.append(
10510 f" {case_path}: models.physics.dimensionality must be one of "
10511 f"{list(DOMAIN_DIMENSIONALITY_MODES)} (got '{dimensionality}')."
10512 )
10513
10514 # --- case.yml: turbulence model validation ---
10515 turbulence_cfg = case_cfg.get('models', {}).get('physics', {}).get('turbulence', {})
10516 if turbulence_cfg is not None and not isinstance(turbulence_cfg, dict):
10517 errors.append(f" {case_path}: 'models.physics.turbulence' must be a mapping.")
10518 elif isinstance(turbulence_cfg, dict) and turbulence_cfg:
10519 try:
10520 # Only the selectors are checked here and the flags are discarded, so unit
10521 # scales stand in for the case's, whose own validation runs separately.
10522 append_turbulence_flags(case_cfg.get('models', {}), [],
10523 {"length_ref": 1.0, "velocity_ref": 1.0})
10524 except ValueError as e:
10525 errors.append(f" {case_path}: {e}")
10526
10527 les_cfg = turbulence_cfg.get('les')
10528 rans_cfg = turbulence_cfg.get('rans')
10529 wall_cfg = turbulence_cfg.get('wall_function')
10530
10531 if isinstance(les_cfg, dict):
10532 validate_les_configuration(case_cfg, les_cfg, case_path, errors, warnings)
10533
10534 validate_wall_model_pairing(case_cfg, les_cfg, wall_cfg, case_path,
10535 errors, warnings)
10536
10537 if rans_cfg is not None:
10538 errors.append(f" {case_path}: {RANS_PLANNED_REFUSAL}")
10539
10540 if isinstance(wall_cfg, dict):
10541 if 'enabled' in wall_cfg and not isinstance(wall_cfg['enabled'], bool):
10542 errors.append(f" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
10543 if 'roughness_height' in wall_cfg:
10544 try:
10545 value = float(wall_cfg['roughness_height'])
10546 if value < 0.0:
10547 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
10548 except (TypeError, ValueError):
10549 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
10550 # Only the log law has a roughness formulation. Werner-Wengle has no
10551 # roughness term and Cabot discards the argument, so accepting the key
10552 # for either would silently ignore a value the user set deliberately.
10553 try:
10554 wall_model = normalize_wall_function_model(wall_cfg.get('model'))
10555 except ValueError:
10556 wall_model = None
10557 if wall_model in (2, 3):
10558 errors.append(
10559 f" {case_path}: models.physics.turbulence.wall_function.roughness_height "
10560 "applies only to model 'log_law'; 'werner' has no roughness formulation and "
10561 "'cabot' ignores it. Remove the key or select 'log_law'.")
10562
10563 # --- solver.yml: basic structure ---
10564 if not isinstance(solver_cfg, dict) or not solver_cfg:
10565 errors.append(f" {solver_path}: solver config is empty or not a valid YAML mapping.")
10566 else:
10567 strategy_cfg = solver_cfg.get('strategy', {})
10568 if not isinstance(strategy_cfg, dict):
10569 errors.append(f" {solver_path}: 'strategy' must be a mapping.")
10570 elif 'implicit' in strategy_cfg:
10571 errors.append(
10572 f" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
10573 "Use 'strategy.momentum_solver' with named solver values."
10574 )
10575 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
10576 try:
10577 normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
10578 except ValueError as e:
10579 errors.append(f" {solver_path}: {e}")
10580
10581 op_mode_cfg = solver_cfg.get('operation_mode', {})
10582 if op_mode_cfg is not None and not isinstance(op_mode_cfg, dict):
10583 errors.append(f" {solver_path}: 'operation_mode' must be a mapping when provided.")
10584 elif isinstance(op_mode_cfg, dict):
10585 eulerian_source_mode = None
10586 normalized_analytical_type = None
10587 if 'eulerian_field_source' in op_mode_cfg:
10588 try:
10589 eulerian_source_mode = normalize_eulerian_field_source(op_mode_cfg.get('eulerian_field_source'))
10590 except ValueError as e:
10591 errors.append(f" {solver_path}: {e}")
10592
10593 analytical_type = op_mode_cfg.get('analytical_type')
10594 if analytical_type is not None:
10595 try:
10596 normalized_analytical_type = normalize_analytical_type(analytical_type)
10597 except ValueError as e:
10598 errors.append(f" {solver_path}: {e}")
10599 else:
10600 uniform_flow_cfg = op_mode_cfg.get('uniform_flow')
10601 if uniform_flow_cfg is not None and not isinstance(uniform_flow_cfg, dict):
10602 errors.append(f" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
10603 elif normalized_analytical_type == "UNIFORM_FLOW":
10604 if not isinstance(uniform_flow_cfg, dict):
10605 errors.append(
10606 f" {solver_path}: operation_mode.uniform_flow is required when "
10607 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
10608 )
10609 else:
10610 for coord in ("u", "v", "w"):
10611 if coord not in uniform_flow_cfg:
10612 errors.append(
10613 f" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
10614 )
10615 else:
10616 try:
10617 float(uniform_flow_cfg[coord])
10618 except (TypeError, ValueError):
10619 errors.append(
10620 f" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
10621 )
10622 elif uniform_flow_cfg is not None:
10623 errors.append(
10624 f" {solver_path}: operation_mode.uniform_flow is only valid when "
10625 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
10626 )
10627
10628 if eulerian_source_mode == "analytical":
10629 effective_analytical_type = normalized_analytical_type or "TGV3D"
10630 if effective_analytical_type == "TGV3D":
10631 # programmatic_c: the solver assigns the [0, 2*pi] TGV domain itself.
10632 # file: the given grid is kept and the field is evaluated at its cell centres.
10633 if grid_mode not in (GRID_MODES[1], GRID_MODES[0]):
10634 errors.append(
10635 f" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
10636 "'programmatic_c' (solver-assigned TGV domain) or 'file' (a grid file the field "
10637 "is evaluated on)."
10638 )
10639 elif grid_mode == 'programmatic_c' and isinstance(grid_cfg.get('programmatic_settings'), dict):
10640 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
10641 if missing_dims:
10642 errors.append(
10643 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
10644 f"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
10645 )
10646 else:
10647 if grid_mode not in (GRID_MODES[1], GRID_MODES[0]):
10648 errors.append(
10649 f" {case_path}: grid.mode '{grid_mode}' is not supported when "
10650 f"operation_mode.analytical_type is '{effective_analytical_type}'. "
10651 "Use 'programmatic_c' or 'file'."
10652 )
10653 elif grid_mode == 'programmatic_c' and isinstance(grid_cfg.get('programmatic_settings'), dict):
10654 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
10655 if missing_dims:
10656 errors.append(
10657 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
10658 f"operation_mode.analytical_type is '{effective_analytical_type}' and "
10659 "grid.mode is 'programmatic_c'."
10660 )
10661
10662 verification_cfg = solver_cfg.get('verification', {})
10663 if verification_cfg is not None and not isinstance(verification_cfg, dict):
10664 errors.append(f" {solver_path}: 'verification' must be a mapping when provided.")
10665 elif isinstance(verification_cfg, dict) and verification_cfg:
10666 sources_cfg = verification_cfg.get('sources', {})
10667 if sources_cfg is not None and not isinstance(sources_cfg, dict):
10668 errors.append(f" {solver_path}: 'verification.sources' must be a mapping when provided.")
10669 elif isinstance(sources_cfg, dict) and sources_cfg:
10670 diff_cfg = sources_cfg.get('diffusivity')
10671 scalar_cfg = sources_cfg.get('scalar')
10672
10673 if diff_cfg is not None:
10674 if not isinstance(diff_cfg, dict):
10675 errors.append(f" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
10676 else:
10677 if eulerian_source_mode != "analytical":
10678 errors.append(
10679 f" {solver_path}: verification.sources.diffusivity is only valid when "
10680 "operation_mode.eulerian_field_source is 'analytical'."
10681 )
10682 mode = diff_cfg.get('mode')
10683 profile = diff_cfg.get('profile')
10684 if str(mode).strip().lower() != "analytical":
10685 errors.append(
10686 f" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
10687 )
10688 if str(profile).strip().upper() != "LINEAR_X":
10689 errors.append(
10690 f" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
10691 )
10692 for key in ("gamma0", "slope_x"):
10693 if key not in diff_cfg:
10694 errors.append(
10695 f" {solver_path}: verification.sources.diffusivity.{key} is required."
10696 )
10697 else:
10698 try:
10699 float(diff_cfg[key])
10700 except (TypeError, ValueError):
10701 errors.append(
10702 f" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
10703 )
10704
10705 if scalar_cfg is not None:
10706 if not isinstance(scalar_cfg, dict):
10707 errors.append(f" {solver_path}: 'verification.sources.scalar' must be a mapping.")
10708 else:
10709 if eulerian_source_mode != "analytical":
10710 errors.append(
10711 f" {solver_path}: verification.sources.scalar is only valid when "
10712 "operation_mode.eulerian_field_source is 'analytical'."
10713 )
10714 mode = scalar_cfg.get('mode')
10715 profile = str(scalar_cfg.get('profile', '')).strip().upper()
10716 if str(mode).strip().lower() != "analytical":
10717 errors.append(
10718 f" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
10719 )
10720 if profile not in VERIFICATION_SCALAR_PROFILES:
10721 errors.append(
10722 f" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
10723 )
10724 required_scalar_keys = {
10725 "CONSTANT": ("value",),
10726 "LINEAR_X": ("phi0", "slope_x"),
10727 "SIN_PRODUCT": ("amplitude", "kx", "ky", "kz"),
10728 }.get(profile, ())
10729 for key in required_scalar_keys:
10730 if key not in scalar_cfg:
10731 errors.append(
10732 f" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
10733 )
10734 else:
10735 try:
10736 float(scalar_cfg[key])
10737 except (TypeError, ValueError):
10738 errors.append(
10739 f" {solver_path}: verification.sources.scalar.{key} must be numeric."
10740 )
10741
10742 unknown_source_keys = sorted(set(sources_cfg.keys()) - {"diffusivity", "scalar"})
10743 if unknown_source_keys:
10744 errors.append(
10745 f" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
10746 "Currently supported: 'diffusivity', 'scalar'."
10747 )
10748 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {"sources"})
10749 if unknown_verification_keys:
10750 errors.append(
10751 f" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
10752 "Currently supported: 'sources'."
10753 )
10754
10755 try:
10756 resolve_scalar_transport(case_cfg, solver_cfg, warn=True)
10757 except ValueError as exc:
10758 errors.append(f" {solver_path}: {exc}")
10759 try:
10760 resolve_les_diagnostics(case_cfg, monitor_cfg, warn=True)
10761 except ValueError as exc:
10762 errors.append(f" {monitor_path}: {exc}")
10763
10764 tolerances_cfg = solver_cfg.get('tolerances', {})
10765 if tolerances_cfg is not None and not isinstance(tolerances_cfg, dict):
10766 errors.append(f" {solver_path}: 'tolerances' must be a mapping when provided.")
10767 elif isinstance(tolerances_cfg, dict):
10768 for key in ("absolute_tol", "relative_tol", "residual_absolute_tol", "residual_relative_tol"):
10769 if key in tolerances_cfg:
10770 try:
10771 float(tolerances_cfg[key])
10772 except (TypeError, ValueError):
10773 errors.append(f" {solver_path}: tolerances.{key} must be numeric.")
10774
10775 ms_cfg = solver_cfg.get('momentum_solver', {})
10776 if ms_cfg is not None and not isinstance(ms_cfg, dict):
10777 errors.append(f" {solver_path}: 'momentum_solver' must be a mapping when provided.")
10778 elif isinstance(ms_cfg, dict):
10779 unsupported_flat_keys = {
10780 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
10781 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
10782 'rk4_residual_noise_allowance_factor'
10783 }
10784 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
10785 if present_unsupported:
10786 errors.append(
10787 f" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
10788 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
10789 )
10790
10791 allowed_ms_keys = {'dual_time_picard_jameson_rk', 'dual_time_picard_rk4', 'newton_krylov'}
10792 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
10793 if unknown_ms_keys:
10794 errors.append(
10795 f" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
10796 "Currently supported: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
10797 )
10798 if 'dual_time_picard_jameson_rk' in ms_cfg and 'dual_time_picard_rk4' in ms_cfg:
10799 errors.append(
10800 f" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
10801 "do not also set its deprecated dual_time_picard_rk4 alias."
10802 )
10803
10804 selected_solver = None
10805 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
10806 try:
10807 selected_solver = normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
10808 except ValueError:
10809 pass
10810 if selected_solver is None:
10811 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
10812
10813 has_dualtime_block = (
10814 'dual_time_picard_jameson_rk' in ms_cfg or 'dual_time_picard_rk4' in ms_cfg
10815 )
10816 if selected_solver != "DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
10817 errors.append(
10818 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
10819 f"{selected_solver}. Solver-specific blocks must match the selected solver."
10820 )
10821
10822 newton_cfg = ms_cfg.get('newton_krylov')
10823 if newton_cfg is not None:
10824 if selected_solver != "newton_krylov":
10825 errors.append(
10826 f" {solver_path}: momentum_solver.newton_krylov is set but selected solver is "
10827 f"{selected_solver}. Solver-specific blocks must match the selected solver."
10828 )
10829 try:
10831 except ValueError as exc:
10832 errors.append(f" {solver_path}: {exc}")
10833
10834 dt_picard_cfg = ms_cfg.get('dual_time_picard_jameson_rk', ms_cfg.get('dual_time_picard_rk4'))
10835 if dt_picard_cfg is not None:
10836 if not isinstance(dt_picard_cfg, dict):
10837 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
10838 else:
10839 allowed_dt_keys = {
10840 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
10841 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
10842 'rk4_residual_noise_allowance_factor', 'ratio_ema_alpha'
10843 }
10844 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
10845 if unknown_dt_keys:
10846 errors.append(
10847 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
10848 )
10849 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
10850 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
10851 errors.append(
10852 f" {solver_path}: use only jameson_residual_noise_allowance_factor; "
10853 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
10854 )
10855 if 'pseudo_cfl' in dt_picard_cfg:
10856 pcfl_cfg = dt_picard_cfg['pseudo_cfl']
10857 if not isinstance(pcfl_cfg, dict):
10858 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
10859 else:
10860 allowed_pcfl_keys = {'initial', 'minimum', 'maximum', 'growth_factor', 'reduction_factor'}
10861 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
10862 if unknown_pcfl_keys:
10863 errors.append(
10864 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
10865 )
10866 numeric_pcfl = {}
10867 for key in allowed_pcfl_keys:
10868 if key in pcfl_cfg:
10869 try:
10870 numeric_pcfl[key] = float(pcfl_cfg[key])
10871 except (TypeError, ValueError):
10872 errors.append(
10873 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
10874 )
10875 if numeric_pcfl.get('minimum', 1.0) <= 0.0:
10876 errors.append(f" {solver_path}: pseudo_cfl.minimum must be positive.")
10877 if numeric_pcfl.get('growth_factor', 1.0) < 1.0:
10878 errors.append(f" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
10879 reduction = numeric_pcfl.get('reduction_factor', 1.0)
10880 if reduction <= 0.0 or reduction >= 1.0:
10881 errors.append(f" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
10882 if all(key in numeric_pcfl for key in ('minimum', 'initial', 'maximum')):
10883 if not numeric_pcfl['minimum'] <= numeric_pcfl['initial'] <= numeric_pcfl['maximum']:
10884 errors.append(f" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
10885 noise_key = (
10886 'jameson_residual_noise_allowance_factor'
10887 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
10888 else 'rk4_residual_noise_allowance_factor'
10889 )
10890 if noise_key in dt_picard_cfg:
10891 try:
10892 if float(dt_picard_cfg[noise_key]) < 1.0:
10893 errors.append(f" {solver_path}: {noise_key} must be at least 1.")
10894 except (TypeError, ValueError):
10895 errors.append(f" {solver_path}: {noise_key} must be numeric.")
10896 if 'ratio_ema_alpha' in dt_picard_cfg:
10897 try:
10898 alpha_val = float(dt_picard_cfg['ratio_ema_alpha'])
10899 if not 0.0 <= alpha_val <= 1.0:
10900 errors.append(f" {solver_path}: ratio_ema_alpha must be in [0, 1].")
10901 except (TypeError, ValueError):
10902 errors.append(f" {solver_path}: ratio_ema_alpha must be numeric.")
10903
10904 # --- solver.yml: interpolation section ---
10905 interp_cfg = solver_cfg.get('interpolation', {}) if isinstance(solver_cfg, dict) else {}
10906 if interp_cfg is not None and not isinstance(interp_cfg, dict):
10907 errors.append(f" {solver_path}: 'interpolation' must be a mapping when provided.")
10908 elif isinstance(interp_cfg, dict) and 'method' in interp_cfg:
10909 try:
10910 interp_code = normalize_interpolation_method(interp_cfg['method'])
10911 except ValueError as e:
10912 errors.append(f" {solver_path}: {e}")
10913 else:
10914 # FaceFlux reads the face fluxes; the analytical types other than UNIFORM_FLOW set Ucat only.
10915 op_mode = solver_cfg.get('operation_mode') if isinstance(solver_cfg.get('operation_mode'), dict) else {}
10916 if (interp_code == 2 and str(op_mode.get('eulerian_field_source', 'solve')).strip().lower() == 'analytical'
10917 and str(op_mode.get('analytical_type', 'TGV3D')).strip().upper() != 'UNIFORM_FLOW'):
10918 errors.append(
10919 f" {solver_path}: interpolation.method 'FaceFlux' needs the face fluxes the flow is "
10920 f"built from, which analytical type '{op_mode.get('analytical_type', 'TGV3D')}' does not set; "
10921 "use 'Trilinear', or the UNIFORM_FLOW analytical type.")
10922
10923 # --- monitor.yml: basic structure ---
10924 if not isinstance(monitor_cfg, dict) or not monitor_cfg:
10925 errors.append(f" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
10926 else:
10927 io_cfg = monitor_cfg.get('io', {})
10928 freq = io_cfg.get('data_output_frequency')
10929 if freq is not None and (not isinstance(freq, int) or freq <= 0):
10930 errors.append(f" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
10931 for key in ('write_initial_checkpoint', 'write_final_checkpoint'):
10932 if key in io_cfg and not isinstance(io_cfg[key], bool):
10933 errors.append(f" {monitor_path}: 'io.{key}' must be true or false (got {io_cfg[key]!r}).")
10934 try:
10935 physics_cfg = (case_cfg.get('models') or {}).get('physics') if isinstance(case_cfg, dict) and isinstance(case_cfg.get('models') or {}, dict) else None
10936 particles_cfg = physics_cfg.get('particles') if isinstance(physics_cfg, dict) else None
10937 particle_count = particles_cfg.get('count', 0) if isinstance(particles_cfg, dict) else 0
10938 if resolve_traced_particle_ids(monitor_cfg) and not particle_count:
10939 errors.append(f" {monitor_path}: 'logging.trace_particles' needs particles; "
10940 "models.physics.particles.count is 0.")
10941 except ValueError as e:
10942 errors.append(f" {monitor_path}: {e}")
10943 particle_console_freq = io_cfg.get('particle_console_output_frequency')
10944 if particle_console_freq is not None and (not isinstance(particle_console_freq, int) or particle_console_freq < 0):
10945 errors.append(
10946 f" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
10947 f"(got {particle_console_freq})."
10948 )
10949 containment_errors, containment_warnings = validate_run_directory_containment(
10950 monitor_cfg, monitor_path
10951 )
10952 errors.extend(containment_errors)
10953 warnings.extend(containment_warnings)
10955 monitor_cfg, monitor_path, "monitor passthrough"))
10956 try:
10957 resolve_profiling_config(monitor_cfg)
10958 except ValueError as e:
10959 errors.append(f" {monitor_path}: {e}")
10960 try:
10961 resolve_diagnostics_config(monitor_cfg)
10962 except ValueError as e:
10963 errors.append(f" {monitor_path}: {e}")
10964 try:
10966 except ValueError as e:
10967 errors.append(f" {monitor_path}: {e}")
10968 try:
10970 except ValueError as e:
10971 errors.append(f" {monitor_path}: {e}")
10972 statistics_console_freq = io_cfg.get('statistics_console_output_frequency')
10973 if statistics_console_freq is not None and (
10974 not isinstance(statistics_console_freq, int)
10975 or isinstance(statistics_console_freq, bool)
10976 or statistics_console_freq < 0):
10977 errors.append(
10978 f" {monitor_path}: 'io.statistics_console_output_frequency' must be a non-negative "
10979 f"integer (got {statistics_console_freq})."
10980 )
10981 try:
10982 normalize_field_statistics_config(monitor_cfg, case_cfg)
10983 except ValueError as e:
10984 errors.append(f" {monitor_path}: {e}")
10985
10986 if not errors and not restart_source_given:
10987 if needs_restart_source(case_cfg, solver_cfg):
10988 warnings.append(
10989 f"{case_path}: This configuration requires restart data (start_step > 0, "
10990 "eulerian_field_source='load', or particle restart_mode='load'). "
10991 "Use --restart-from or --continue when running."
10992 )
10993
10994 if errors:
10996 for warning in warnings:
10997 print(f"[WARNING] {warning}", file=sys.stderr)
10998
10999
11000def check_post_checkpoint_cadence_alignment(post_cfg: dict, monitor_cfg: dict, post_path: str,
11001 monitor_path: str = "monitor.yml") -> "tuple[list, list]":
11002 """!
11003 @brief Report post step selections that cannot land on a committed checkpoint.
11004
11005 @details The post-processor reads committed bundles, and the solver commits one
11006 every `io.data_output_frequency` completed steps. A `step_interval` that
11007 is not a multiple of that cadence therefore asks for steps that were
11008 never written: those steps wait forever for a checkpoint, and far less
11009 than the recipe requested is processed. That is only
11010 discovered after a solve has already run, so it is caught here instead.
11011
11012 The solver also commits the initial and final states off cadence, which
11013 is why a misaligned `start_step` is a warning rather than an error: it
11014 may legitimately be the run's initial or final step. A bounded selection
11015 containing only one checkpoint has no stride to align; its interval must
11016 still be positive. Multi-checkpoint selections retain the cadence check.
11017
11018 @param[in] post_cfg Parsed post-processing configuration.
11019 @param[in] monitor_cfg Parsed monitor configuration governing the source run.
11020 @param[in] post_path Path to the post file, for error messages.
11021 @param[in] monitor_path Path to the monitor file, for error messages.
11022 @return `(errors, warnings)`, each a list of message strings. Both are empty when
11023 the comparison cannot be made, because the inputs that would make it
11024 meaningful are validated and reported elsewhere.
11025 """
11026 errors = []
11027 warnings = []
11028 if not isinstance(post_cfg, dict) or not isinstance(monitor_cfg, dict):
11029 return errors, warnings
11030
11031 io_cfg = monitor_cfg.get("io") or {}
11032 if not isinstance(io_cfg, dict):
11033 return errors, warnings
11034 try:
11035 cadence = int(io_cfg["data_output_frequency"])
11036 except (KeyError, TypeError, ValueError):
11037 # A missing or malformed cadence is reported by the monitor's own checks.
11038 return errors, warnings
11039 # A disabled cadence commits only the initial and final states, so there is no
11040 # stride to align to.
11041 if cadence <= 0:
11042 return errors, warnings
11043
11044 try:
11045 start_step, end_step, step_interval = resolve_post_requested_window(post_cfg)
11046 except (TypeError, ValueError):
11047 # Reported by the run_control checks, which run against the same values.
11048 return errors, warnings
11049
11050 single_checkpoint = end_step >= start_step and end_step - start_step < step_interval
11051 if not single_checkpoint and step_interval % cadence != 0:
11052 suggestion = max(cadence, (step_interval // cadence) * cadence)
11053 errors.append(
11054 f" {post_path}: 'run_control.step_interval' is {step_interval}, which is not a "
11055 f"multiple of 'io.data_output_frequency' ({cadence}) in {monitor_path}. The solver "
11056 f"only commits a checkpoint every {cadence} steps, so most requested steps were "
11057 f"never written and post-processing would stop at the first missing one. Use "
11058 f"{suggestion}, or another multiple of {cadence}, or lower the monitor cadence."
11059 )
11060
11061 if start_step > 0 and start_step % cadence != 0:
11062 warnings.append(
11063 f"{post_path}: 'run_control.start_step' is {start_step}, which is not a multiple of "
11064 f"'io.data_output_frequency' ({cadence}) in {monitor_path}. That step only exists if "
11065 f"it is the run's initial or final step, which is committed off cadence."
11066 )
11067
11068 return errors, warnings
11069
11070
11071def validate_post_config(post_cfg: dict, post_path: str, monitor_cfg: dict = None, case_cfg: dict = None):
11072 """!
11073 @brief Validates the post-processing config before running the post-processor.
11074 @param[in] post_cfg Parsed post-processing YAML dictionary.
11075 @param[in] post_path Path to post file (for error messages).
11076 @param[in] monitor_cfg Parsed monitor configuration, when available. Two checks
11077 span both files: field statistics, where post.yml names
11078 the windows and monitor.yml decides what each accumulates,
11079 and step cadence, where monitor.yml decides which steps
11080 exist to be read. Both run only when the monitor is known.
11081 @param[in] case_cfg Parsed case configuration, when available. Spectra
11082 preconditions need the boundary conditions and block count, so
11083 they are checked only when the case is known; validating a
11084 recipe on its own still checks it on its own terms.
11085 @throws SystemExit on validation failure.
11086 """
11087 errors = []
11088 warnings = []
11089
11090 _validate_yaml_schema_keys(post_cfg, _POST_SCHEMA, post_path, errors)
11091
11092 try:
11094 except ValueError as e:
11095 recipe = None
11096 errors.append(f" {post_path}: {e}")
11097
11098 try:
11099 spectra_recipe = normalize_post_spectra_config(post_cfg)
11100 except ValueError as e:
11101 spectra_recipe = None
11102 errors.append(f" {post_path}: {e}")
11103 if recipe is not None:
11104 try:
11106 except ValueError as e:
11107 errors.append(f" {post_path}: {e}")
11108 if spectra_recipe and spectra_recipe["tasks"] and case_cfg is not None:
11109 errors.extend(validate_post_spectra_preconditions(spectra_recipe, case_cfg, post_path))
11110 if recipe and recipe["windows"] and monitor_cfg is not None:
11111 try:
11112 configured = normalize_field_statistics_config(monitor_cfg)
11113 except ValueError:
11114 # The monitor file reports its own errors; do not repeat them here.
11115 configured = None
11116 if configured is not None:
11117 if not configured["enabled"]:
11118 errors.append(
11119 f" {post_path}: field statistics are requested, but "
11120 "'field_statistics.enabled' is not set in the monitor configuration, so no "
11121 "window is accumulated."
11122 )
11123 else:
11124 by_name = {window["name"]: window for window in configured["windows"]}
11125 for name in recipe["windows"]:
11126 if name not in by_name:
11127 errors.append(
11128 f" {post_path}: field-statistics window '{name}' is not defined in "
11129 f"the monitor configuration. Defined windows: {sorted(by_name)}."
11130 )
11131 elif _post_window_derived_field_count(by_name[name], recipe["outputs"]) == 0:
11132 errors.append(
11133 f" {post_path}: outputs {recipe['outputs']} produce no field for "
11134 f"window '{name}'; it accumulates none of the state they need. Add "
11135 "'second' to a field's moments for stresses, RMS, or turbulent "
11136 "kinetic energy, or a covariance for a flux."
11137 )
11138
11139 for ensemble in (recipe or {}).get("ensembles", []):
11140 eligible = _ensemble_source_fields(ensemble["source"])
11141 if _post_window_derived_field_count(ensemble, recipe["outputs"], eligible) == 0:
11142 errors.append(
11143 f" {post_path}: outputs {recipe['outputs']} produce no field for ensemble "
11144 f"'{ensemble['name']}'; add 'second' to a field's moments or a covariance."
11145 )
11146 if case_cfg is not None:
11147 requirements = {eligible[entry["field"]]["requires"] for entry in ensemble["fields"]}
11148 for requirement in sorted(r for r in requirements if r):
11149 if not _statistics_subsystem_available(case_cfg, requirement):
11150 errors.append(
11151 f" {post_path}: ensemble '{ensemble['name']}' needs the '{requirement}' "
11152 "subsystem, which is not enabled for this case."
11153 )
11154
11155 if not isinstance(post_cfg, dict) or not post_cfg:
11156 errors.append(f" {post_path}: post-processing config is empty or not a valid YAML mapping.")
11158
11159 # --- run_control ---
11160 if 'run_control' not in post_cfg:
11161 errors.append(f" {post_path}: missing required section 'run_control'.")
11162 else:
11163 rc = post_cfg.get('run_control', {})
11164 if not isinstance(rc, dict):
11165 errors.append(f" {post_path}: 'run_control' must be a mapping.")
11166 else:
11167 for canonical_key, aliases in POST_RUN_CONTROL_ALIASES.items():
11168 if not any(alias in rc for alias in aliases):
11169 alias_list = "', '".join(aliases)
11170 errors.append(
11171 f" {post_path}: missing required key 'run_control.{canonical_key}' "
11172 f"(accepted aliases: '{alias_list}')."
11173 )
11174 continue
11175 raw_value = _mapping_value_with_aliases(rc, *aliases)
11176 try:
11177 value = int(raw_value)
11178 if canonical_key == "step_interval" and value <= 0:
11179 errors.append(f" {post_path}: 'run_control.step_interval' must be positive.")
11180 except (TypeError, ValueError):
11181 alias_name = next((alias for alias in aliases if alias in rc), canonical_key)
11182 errors.append(
11183 f" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
11184 )
11185
11186 # --- io section ---
11187 io_cfg = post_cfg.get('io', {})
11188 source_cfg = post_cfg.get('source_data')
11189 if source_cfg is not None and not isinstance(source_cfg, dict):
11190 errors.append(f" {post_path}: 'source_data' must be a mapping when provided.")
11191 global_ops = post_cfg.get('global_operations')
11192 if global_ops is not None:
11193 if not isinstance(global_ops, dict):
11194 errors.append(f" {post_path}: 'global_operations' must be a mapping when provided.")
11195 elif 'dimensionalize' in global_ops and not isinstance(global_ops.get('dimensionalize'), bool):
11196 errors.append(f" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
11197 if not io_cfg:
11198 errors.append(f" {post_path}: missing required section 'io'.")
11199 elif not isinstance(io_cfg, dict):
11200 errors.append(f" {post_path}: 'io' must be a mapping.")
11201 else:
11202 for k in ['output_filename_prefix']:
11203 if k not in io_cfg:
11204 errors.append(f" {post_path}: missing required key 'io.{k}'.")
11205 for key_name in ('output_directory', 'output_filename_prefix', 'particle_filename_prefix'):
11206 if key_name in io_cfg and not isinstance(io_cfg.get(key_name), str):
11207 errors.append(f" {post_path}: 'io.{key_name}' must be a string when provided.")
11208 # The conductor files every post artifact by recipe identity, which excludes
11209 # this key, so two recipes differing only in it write over each other.
11210 if 'output_directory' in io_cfg:
11211 warnings.append(
11212 f"{post_path}: 'io.output_directory' is ignored; post output goes to the run's "
11213 "visualization and analysis directories under the recipe's identity, so recipes "
11214 "differing only in this key share one home and overwrite each other. Change "
11215 "'io.output_filename_prefix' to keep them apart."
11216 )
11217 if 'output_particles' in io_cfg and not isinstance(io_cfg.get('output_particles'), bool):
11218 errors.append(f" {post_path}: 'io.output_particles' must be a boolean when provided.")
11219 try:
11221 except ValueError as exc:
11222 errors.append(f" {post_path}: {exc}")
11223 particle_subsampling_frequency = io_cfg.get('particle_subsampling_frequency')
11224 if particle_subsampling_frequency is not None:
11225 if not isinstance(particle_subsampling_frequency, int) or particle_subsampling_frequency <= 0:
11226 errors.append(
11227 f" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
11228 )
11229 input_extensions = io_cfg.get('input_extensions')
11230 source_input_extensions = get_post_source_data(post_cfg).get('input_extensions')
11231 if input_extensions is not None:
11232 if not isinstance(input_extensions, dict):
11233 errors.append(f" {post_path}: 'io.input_extensions' must be a mapping when provided.")
11234 else:
11235 for ext_key in ('eulerian', 'particle'):
11236 ext_val = input_extensions.get(ext_key)
11237 if ext_val is not None and not isinstance(ext_val, str):
11238 errors.append(f" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
11239 elif ext_val is not None and str(ext_val).strip().lstrip('.').lower() != 'dat':
11240 errors.append(
11241 f" {post_path}: 'io.input_extensions.{ext_key}' must be 'dat'; "
11242 "committed checkpoint payload names are fixed."
11243 )
11244 if source_input_extensions is not None:
11245 if not isinstance(source_input_extensions, dict):
11246 errors.append(f" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
11247 else:
11248 for ext_key in ('eulerian', 'particle'):
11249 ext_val = source_input_extensions.get(ext_key)
11250 if ext_val is not None and not isinstance(ext_val, str):
11251 errors.append(
11252 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
11253 )
11254 elif ext_val is not None and str(ext_val).strip().lstrip('.').lower() != 'dat':
11255 errors.append(
11256 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be 'dat'; "
11257 "committed checkpoint payload names are fixed."
11258 )
11259
11260 for list_key in ('eulerian_fields', 'particle_fields'):
11261 list_val = io_cfg.get(list_key)
11262 if list_val is not None and (not isinstance(list_val, list)
11263 or not all(isinstance(item, str) for item in list_val)):
11264 errors.append(f" {post_path}: 'io.{list_key}' must be a list of field names when provided.")
11265 # The nodal_average inputs are computed at cell centres, and a .vts carries point data
11266 # only: the writer skips them, so a listed one would silently never reach the file.
11267 eulerian_fields = io_cfg.get('eulerian_fields')
11268 for cell_field in ('Ucat', 'P', 'Psi', 'Qcrit'):
11269 if isinstance(eulerian_fields, list) and cell_field in eulerian_fields:
11270 after = " after q_criterion" if cell_field == 'Qcrit' else ""
11271 errors.append(
11272 f" {post_path}: 'io.eulerian_fields' lists '{cell_field}', which is cell-centred and "
11273 f"cannot be written as point data. Add a nodal_average task (input_field: {cell_field}, "
11274 f"output_field: {cell_field}_nodal){after} and write '{cell_field}_nodal'."
11275 )
11276
11277 # --- Check eulerian_pipeline entries have 'task' key ---
11278 eulerian_pipeline = post_cfg.get('eulerian_pipeline', [])
11279 if eulerian_pipeline is not None and not isinstance(eulerian_pipeline, list):
11280 errors.append(f" {post_path}: 'eulerian_pipeline' must be a list when provided.")
11281 eulerian_pipeline = []
11282 for i, entry in enumerate(eulerian_pipeline):
11283 if not isinstance(entry, dict) or 'task' not in entry:
11284 errors.append(f" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
11285 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
11286 continue
11287 task_name = entry.get('task')
11288 if task_name == 'q_criterion':
11289 continue
11290 if task_name == 'nodal_average':
11291 in_field = entry.get('input_field')
11292 out_field = entry.get('output_field')
11293 if not isinstance(in_field, str) or not in_field.strip():
11294 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
11295 if not isinstance(out_field, str) or not out_field.strip():
11296 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
11297 if isinstance(in_field, str) and isinstance(out_field, str) and in_field == out_field:
11298 errors.append(
11299 f" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
11300 )
11301 continue
11302 if task_name == 'normalize_field':
11303 field = entry.get('field', 'P')
11304 if not isinstance(field, str) or not field.strip():
11305 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
11306 elif field != 'P':
11307 errors.append(
11308 f" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
11309 f"(got '{field}')."
11310 )
11311 reference_point = entry.get('reference_point', [1, 1, 1])
11312 if not isinstance(reference_point, (list, tuple)) or len(reference_point) != 3:
11313 errors.append(
11314 f" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
11315 )
11316 else:
11317 for rp_idx, coord in enumerate(reference_point):
11318 try:
11319 int(coord)
11320 except (TypeError, ValueError):
11321 errors.append(
11322 f" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
11323 "must be integer-compatible."
11324 )
11325 continue
11326 errors.append(
11327 f" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]. "
11328 f"Available tasks: {list(POST_EULERIAN_PIPELINE_TASKS)}."
11329 )
11330
11331 # --- Check lagrangian_pipeline entries have 'task' key ---
11332 lagrangian_pipeline = post_cfg.get('lagrangian_pipeline', [])
11333 if lagrangian_pipeline is not None and not isinstance(lagrangian_pipeline, list):
11334 errors.append(f" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
11335 lagrangian_pipeline = []
11336 for i, entry in enumerate(lagrangian_pipeline):
11337 if not isinstance(entry, dict) or 'task' not in entry:
11338 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
11339 continue
11340 task_name = entry.get('task')
11341 if task_name == 'specific_ke':
11342 in_field = entry.get('input_field')
11343 out_field = entry.get('output_field')
11344 if not isinstance(in_field, str) or not in_field.strip():
11345 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
11346 if not isinstance(out_field, str) or not out_field.strip():
11347 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
11348 continue
11349 errors.append(
11350 f" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
11351 )
11352
11353 # --- Check statistics pipeline entries ---
11354 stats_cfg = post_cfg.get('statistics_pipeline')
11355 stats_entries = []
11356 if stats_cfg is not None:
11357 if isinstance(stats_cfg, list):
11358 stats_entries = stats_cfg
11359 elif isinstance(stats_cfg, dict):
11360 stats_entries = stats_cfg.get('tasks', [])
11361 if not isinstance(stats_entries, list):
11362 errors.append(f" {post_path}: 'statistics_pipeline.tasks' must be a list.")
11363 stats_output_prefix = stats_cfg.get('output_prefix')
11364 if stats_output_prefix is not None and not isinstance(stats_output_prefix, str):
11365 errors.append(f" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
11366 else:
11367 errors.append(
11368 f" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
11369 )
11370 for i, entry in enumerate(stats_entries):
11371 if isinstance(entry, str):
11372 task_name = entry
11373 elif isinstance(entry, dict) and 'task' in entry:
11374 task_name = entry.get('task')
11375 else:
11376 errors.append(
11377 f" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
11378 )
11379 continue
11380 try:
11381 normalize_statistics_task(task_name)
11382 except ValueError as e:
11383 errors.append(f" {post_path}: {e}")
11384
11385 # MSD measures displacement from the configured point source, not from each
11386 # particle's own start. Any other seeding leaves that point unset - the coordinate
11387 # origin - and the statistic measures distance from the origin instead. With no
11388 # particles the task writes nothing, so only a seeded case is refused.
11389 if case_cfg is not None and "ComputeMSD" in get_post_statistics_task_tokens(post_cfg):
11390 particles_cfg = (((case_cfg.get('models') or {}).get('physics') or {}).get('particles') or {})
11391 try:
11392 particle_count = int(particles_cfg.get('count', 0) or 0)
11393 except (TypeError, ValueError):
11394 particle_count = 0
11395 init_mode = particles_cfg.get('init_mode', 'Surface')
11396 try:
11397 is_point_source = normalize_particle_init_mode(init_mode) == 2
11398 except ValueError:
11399 is_point_source = True # the case file reports its own init_mode error
11400 if particle_count > 0 and not is_point_source:
11401 errors.append(
11402 f" {post_path}: statistics task 'msd' measures displacement from "
11403 f"models.physics.particles.point_source, so it requires init_mode: PointSource "
11404 f"(the case seeds with '{init_mode}')."
11405 )
11406
11407 legacy_stats_output_prefix = post_cfg.get('statistics_output_prefix')
11408 if legacy_stats_output_prefix is not None and not isinstance(legacy_stats_output_prefix, str):
11409 errors.append(f" {post_path}: 'statistics_output_prefix' must be a string when provided.")
11410
11411 # Runs last so it reports against values the run_control checks above have
11412 # already established are integers.
11413 cadence_errors, cadence_warnings = check_post_checkpoint_cadence_alignment(
11414 post_cfg, monitor_cfg, post_path)
11415 errors.extend(cadence_errors)
11416 warnings.extend(cadence_warnings)
11417
11418 if errors:
11420 for warning in warnings:
11421 print(f"[WARNING] {warning}", file=sys.stderr)
11422
11423def validate_cluster_config(cluster_cfg: dict, cluster_path: str):
11424 """!
11425 @brief Validate Slurm scheduler configuration from cluster.yml.
11426 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
11427 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
11428 """
11429 errors = []
11430 warnings = []
11431 _validate_yaml_schema_keys(cluster_cfg, _CLUSTER_SCHEMA, cluster_path, errors)
11432 if not isinstance(cluster_cfg, dict) or not cluster_cfg:
11433 errors.append(f" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
11435
11436 scheduler = cluster_cfg.get("scheduler", {})
11437 if not isinstance(scheduler, dict):
11438 errors.append(f" {cluster_path}: 'scheduler' must be a mapping.")
11439 else:
11440 scheduler_type = scheduler.get("type", "slurm")
11441 if str(scheduler_type).lower() != "slurm":
11442 errors.append(f" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
11443
11444 resources = cluster_cfg.get("resources", {})
11445 if not isinstance(resources, dict):
11446 errors.append(f" {cluster_path}: 'resources' must be a mapping.")
11447 else:
11448 for req in ("account", "nodes", "ntasks_per_node", "mem", "time"):
11449 if req not in resources:
11450 errors.append(f" {cluster_path}: missing required key 'resources.{req}'.")
11451 for int_key in ("nodes", "ntasks_per_node"):
11452 if int_key in resources:
11453 val = resources.get(int_key)
11454 if not isinstance(val, int) or val <= 0:
11455 errors.append(f" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
11456 for str_key in ("account", "mem", "time", "partition"):
11457 if str_key in resources and resources.get(str_key) is not None:
11458 if not isinstance(resources.get(str_key), str):
11459 errors.append(f" {cluster_path}: resources.{str_key} must be a string when provided.")
11460 if isinstance(resources.get("time"), str):
11461 try:
11462 parse_slurm_time_limit_to_seconds(resources["time"])
11463 except ValueError as exc:
11464 errors.append(
11465 f" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
11466 )
11467 account = resources.get("account")
11468 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
11469 warnings.append(
11470 f"{cluster_path}: resources.account still uses the sample placeholder "
11471 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
11472 )
11473
11474 notifications = cluster_cfg.get("notifications", {})
11475 if notifications is not None and not isinstance(notifications, dict):
11476 errors.append(f" {cluster_path}: 'notifications' must be a mapping when provided.")
11477 elif isinstance(notifications, dict):
11478 mail_user = notifications.get("mail_user")
11479 if mail_user is not None and not is_valid_email(mail_user):
11480 errors.append(f" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
11481 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
11482 warnings.append(
11483 f"{cluster_path}: notifications.mail_user still uses the sample placeholder "
11484 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
11485 )
11486 mail_type = notifications.get("mail_type")
11487 if mail_type is not None and not isinstance(mail_type, str):
11488 errors.append(f" {cluster_path}: notifications.mail_type must be a string when provided.")
11489
11490 execution = cluster_cfg.get("execution", {})
11491 if execution is not None and not isinstance(execution, dict):
11492 errors.append(f" {cluster_path}: 'execution' must be a mapping when provided.")
11493 elif isinstance(execution, dict):
11494 module_setup = execution.get("module_setup", [])
11495 if module_setup is not None and not isinstance(module_setup, list):
11496 errors.append(f" {cluster_path}: execution.module_setup must be a list of shell lines.")
11497 elif isinstance(module_setup, list):
11498 for i, line in enumerate(module_setup):
11499 if not isinstance(line, str):
11500 errors.append(f" {cluster_path}: execution.module_setup[{i}] must be a string.")
11501
11502 launcher = execution.get("launcher")
11503 if launcher is not None and not isinstance(launcher, str):
11504 errors.append(f" {cluster_path}: execution.launcher must be a string when provided.")
11505 launcher_args = execution.get("launcher_args")
11506 if launcher_args is not None and not isinstance(launcher_args, list):
11507 errors.append(f" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
11508 elif isinstance(launcher_args, list):
11509 for i, token in enumerate(launcher_args):
11510 if not isinstance(token, (str, int, float)):
11511 errors.append(f" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
11513 errors.append(
11514 f" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
11515 "split whitespace-separated arguments into separate list items."
11516 )
11517 if (launcher is None or isinstance(launcher, str)) and (launcher_args is None or isinstance(launcher_args, list)):
11518 try:
11520 except ValueError as exc:
11521 errors.append(f" {cluster_path}: {exc}.")
11522
11523 extra_sbatch = execution.get("extra_sbatch")
11524 if extra_sbatch is not None and not isinstance(extra_sbatch, (dict, list)):
11525 errors.append(f" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
11526
11527 walltime_guard = execution.get("walltime_guard")
11528 if walltime_guard is not None and not isinstance(walltime_guard, dict):
11529 errors.append(f" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
11530 elif isinstance(walltime_guard, dict):
11531 enabled = walltime_guard.get("enabled")
11532 if enabled is not None and not isinstance(enabled, bool):
11533 errors.append(f" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
11534
11535 warmup_steps = walltime_guard.get("warmup_steps")
11536 if warmup_steps is not None and (not isinstance(warmup_steps, int) or isinstance(warmup_steps, bool) or warmup_steps <= 0):
11537 errors.append(
11538 f" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
11539 )
11540
11541 multiplier = walltime_guard.get("multiplier")
11542 if multiplier is not None:
11543 if isinstance(multiplier, bool) or not isinstance(multiplier, (int, float)) or multiplier <= 0.0:
11544 errors.append(
11545 f" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
11546 )
11547 elif float(multiplier) > 5.0:
11548 errors.append(
11549 f" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
11550 )
11551
11552 min_seconds = walltime_guard.get("min_seconds")
11553 if min_seconds is not None and (
11554 isinstance(min_seconds, bool) or not isinstance(min_seconds, (int, float)) or float(min_seconds) <= 0.0
11555 ):
11556 errors.append(
11557 f" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
11558 )
11559
11560 estimator_alpha = walltime_guard.get("estimator_alpha")
11561 if estimator_alpha is not None:
11562 if isinstance(estimator_alpha, bool) or not isinstance(estimator_alpha, (int, float)):
11563 errors.append(
11564 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
11565 )
11566 elif float(estimator_alpha) <= 0.0 or float(estimator_alpha) > 1.0:
11567 errors.append(
11568 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
11569 )
11570
11571 if warnings:
11572 for warning in warnings:
11573 print(f"[WARNING] {warning}", file=sys.stderr)
11574
11575 if errors:
11577
11578def validate_study_config(study_cfg: dict, study_path: str, skip_base_file_check: bool = False):
11579 """!
11580 @brief Validate sweep/study specification from study.yml.
11581 @param[in] study_cfg Argument passed to `validate_study_config()`.
11582 @param[in] study_path Argument passed to `validate_study_config()`.
11583 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
11584 """
11585 errors = []
11586 _validate_yaml_schema_keys(study_cfg, _STUDY_SCHEMA, study_path, errors)
11587 if not isinstance(study_cfg, dict) or not study_cfg:
11588 errors.append(f" {study_path}: study config is empty or not a valid YAML mapping.")
11590
11591 base_cfgs = study_cfg.get("base_configs")
11592 if not isinstance(base_cfgs, dict):
11593 errors.append(f" {study_path}: missing required mapping 'base_configs'.")
11594 else:
11595 for req in ("case", "solver", "monitor", "post"):
11596 path_val = base_cfgs.get(req)
11597 if not path_val or not isinstance(path_val, str):
11598 errors.append(f" {study_path}: base_configs.{req} must be a path string.")
11599 elif not skip_base_file_check:
11600 resolved = resolve_path(study_path, path_val)
11601 if not os.path.isfile(resolved):
11602 errors.append(f" {study_path}: base_configs.{req} does not exist: {resolved}")
11603
11604 study_type = study_cfg.get("study_type")
11605 allowed_types = set(STUDY_TYPES)
11606 if study_type not in allowed_types:
11607 errors.append(
11608 f" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
11609 )
11610
11611 parameters = study_cfg.get("parameters")
11612 parameter_sets = study_cfg.get("parameter_sets")
11613 allowed_roots = {"case", "solver", "monitor", "post"}
11614 if bool(parameters) == bool(parameter_sets):
11615 errors.append(f" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
11616 elif parameter_sets:
11617 if not isinstance(parameter_sets, list) or not parameter_sets:
11618 errors.append(f" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
11619 else:
11620 for set_index, param_set in enumerate(parameter_sets):
11621 if not isinstance(param_set, dict) or not param_set:
11622 errors.append(
11623 f" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
11624 )
11625 continue
11626 for key, value in param_set.items():
11627 if not isinstance(key, str) or "." not in key:
11628 errors.append(
11629 f" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
11630 )
11631 continue
11632 root = key.split(".", 1)[0]
11633 if root not in allowed_roots:
11634 errors.append(
11635 f" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
11636 )
11637 if isinstance(value, (dict, list)):
11638 errors.append(
11639 f" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
11640 )
11641 else:
11642 if not isinstance(parameters, dict) or not parameters:
11643 errors.append(f" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
11644 else:
11645 for key, values in parameters.items():
11646 if not isinstance(key, str) or "." not in key:
11647 errors.append(
11648 f" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
11649 )
11650 continue
11651 root = key.split(".", 1)[0]
11652 if root not in allowed_roots:
11653 errors.append(
11654 f" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
11655 )
11656 if not isinstance(values, list) or len(values) == 0:
11657 errors.append(f" {study_path}: parameters.{key} must be a non-empty list.")
11658
11659 metrics = study_cfg.get("metrics", [])
11660 if metrics is not None and not isinstance(metrics, list):
11661 errors.append(f" {study_path}: 'metrics' must be a list when provided.")
11662 elif isinstance(metrics, list):
11663 for i, metric in enumerate(metrics):
11664 if isinstance(metric, str):
11665 continue
11666 if not isinstance(metric, dict):
11667 errors.append(
11668 f" {study_path}: metrics[{i}] must be a string or mapping."
11669 )
11670 continue
11671 if "name" not in metric:
11672 errors.append(f" {study_path}: metrics[{i}] missing required key 'name'.")
11673 if "source" not in metric:
11674 errors.append(f" {study_path}: metrics[{i}] missing required key 'source'.")
11675 if str(metric.get("source", "")).strip().lower() in METRIC_SOURCE_KINDS[2:]:
11676 # extract_metric_from_log reads group(1); without it every case scores empty.
11677 regex = metric.get("regex")
11678 try:
11679 has_group = isinstance(regex, str) and re.compile(regex).groups >= 1
11680 except re.error:
11681 has_group = False
11682 if not has_group:
11683 errors.append(
11684 f" {study_path}: metrics[{i}].regex must be a valid regular expression "
11685 "with a capture group for the value when source is a log."
11686 )
11687 for label_key in ("plot_label", "label", "units"):
11688 label_value = metric.get(label_key)
11689 if label_value is not None and (not isinstance(label_value, str) or not label_value.strip()):
11690 errors.append(
11691 f" {study_path}: metrics[{i}].{label_key} must be a non-empty string when provided."
11692 )
11693
11694 plotting = study_cfg.get("plotting", {})
11695 if plotting is not None and not isinstance(plotting, dict):
11696 errors.append(f" {study_path}: 'plotting' must be a mapping when provided.")
11697 elif isinstance(plotting, dict):
11698 enabled = plotting.get("enabled")
11699 if enabled is not None and not isinstance(enabled, bool):
11700 errors.append(f" {study_path}: plotting.enabled must be boolean when provided.")
11701 output_format = plotting.get("output_format")
11702 if output_format is not None and output_format not in STUDY_PLOT_FORMATS:
11703 errors.append(f" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
11704
11705 execution = study_cfg.get("execution", {})
11706 if execution is not None and not isinstance(execution, dict):
11707 errors.append(f" {study_path}: 'execution' must be a mapping when provided.")
11708 elif isinstance(execution, dict):
11709 max_conc = execution.get("max_concurrent_array_tasks")
11710 if max_conc is not None and (not isinstance(max_conc, int) or max_conc <= 0):
11711 errors.append(
11712 f" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
11713 )
11714
11715 if errors:
11717
11718def _deep_set(container: dict, dotted_path: str, value):
11719 """!
11720 @brief Set nested dictionary value, creating intermediate maps when needed.
11721 @param[in] container Argument passed to `_deep_set()`.
11722 @param[in] dotted_path Argument passed to `_deep_set()`.
11723 @param[in] value Argument passed to `_deep_set()`.
11724 """
11725 keys = dotted_path.split(".")
11726 current = container
11727 for key in keys[:-1]:
11728 if key not in current or not isinstance(current[key], dict):
11729 current[key] = {}
11730 current = current[key]
11731 current[keys[-1]] = value
11732
11733def expand_parameter_matrix(parameters: dict) -> list:
11734 """!
11735 @brief Expand study parameter lists into cartesian-product combinations.
11736 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
11737 @return Value returned by `expand_parameter_matrix()`.
11738 """
11739 param_keys = list(parameters.keys())
11740 all_values = [parameters[k] for k in param_keys]
11741 combos = []
11742 for combo in itertools.product(*all_values):
11743 combos.append(dict(zip(param_keys, combo)))
11744 return combos
11745
11746
11747def expand_study_parameter_combinations(study_cfg: dict) -> list:
11748 """!
11749 @brief Expand either cartesian-study parameters or explicit parameter sets.
11750 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
11751 @return Value returned by `expand_study_parameter_combinations()`.
11752 """
11753 parameter_sets = study_cfg.get("parameter_sets")
11754 if parameter_sets:
11755 return [dict(param_set) for param_set in parameter_sets]
11756 return expand_parameter_matrix(study_cfg.get("parameters") or {})
11757
11758
11759def flatten_study_parameters(parameters: dict) -> dict:
11760 """!
11761 @brief Flatten grouped study overrides into scalar dotted-path columns.
11762
11763 @details A grouped override such as `case.run_control: {dt_physical: ...}`
11764 materializes correctly in case YAML but is not a useful CSV cell or
11765 plot coordinate. Flattening preserves the actual varied variables.
11766
11767 @param[in] parameters One expanded study parameter combination.
11768 @return Flat dotted-path-to-scalar mapping.
11769 """
11770 flattened = {}
11771
11772 def visit(prefix, value):
11773 """!
11774 @brief Recursively flatten one grouped override value.
11775 @param[in] prefix Current dotted parameter path.
11776 @param[in] value Scalar or nested mapping at the current path.
11777 """
11778 if isinstance(value, dict):
11779 for child, child_value in value.items():
11780 visit(f"{prefix}.{child}" if prefix else str(child), child_value)
11781 else:
11782 flattened[prefix] = value
11783
11784 for key, value in (parameters or {}).items():
11785 visit(str(key), value)
11786 return flattened
11787
11788
11789def get_study_parameter_keys(study_cfg: dict) -> list:
11790 """!
11791 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
11792 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
11793 @return Value returned by `get_study_parameter_keys()`.
11794 """
11795 parameters = study_cfg.get("parameters")
11796 if isinstance(parameters, dict) and parameters:
11797 keys = []
11798 for key, candidates in parameters.items():
11799 candidate_dicts = [value for value in candidates if isinstance(value, dict)] if isinstance(candidates, list) else []
11800 expanded = []
11801 for value in candidate_dicts:
11802 for flat_key in flatten_study_parameters({key: value}):
11803 if flat_key not in expanded:
11804 expanded.append(flat_key)
11805 for flat_key in expanded or [key]:
11806 if flat_key not in keys:
11807 keys.append(flat_key)
11808 return keys
11809
11810 keys = []
11811 parameter_sets = study_cfg.get("parameter_sets") or []
11812 for param_set in parameter_sets:
11813 if not isinstance(param_set, dict):
11814 continue
11815 for key in flatten_study_parameters(param_set):
11816 if key not in keys:
11817 keys.append(key)
11818 return keys
11819
11820
11821def get_cluster_total_tasks(cluster_cfg: dict) -> int:
11822 """!
11823 @brief Return cluster total tasks.
11824 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
11825 @return Value returned by `get_cluster_total_tasks()`.
11826 """
11827 resources = cluster_cfg.get("resources", {})
11828 return int(resources.get("nodes", 1)) * int(resources.get("ntasks_per_node", 1))
11829
11830def normalize_extension(ext: str) -> str:
11831 """!
11832 @brief Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
11833 @param[in] ext Argument passed to `normalize_extension()`.
11834 @return Value returned by `normalize_extension()`.
11835 """
11836 if ext is None:
11837 return None
11838 return str(ext).strip().lstrip(".")
11839
11841 """!
11842 @brief Resolve how to invoke this conductor again from a batch script.
11843
11844 @details Prefers the `bin/picurv` wrapper, which selects the managed Python
11845 environment a cluster node needs, and falls back to the running
11846 interpreter with the package entry point when that wrapper is absent.
11847
11848 @return Argv prefix that re-invokes the conductor.
11849 """
11850 wrapper = os.path.join(PACKAGE_PROJECT_ROOT, "bin", "picurv")
11851 if os.path.isfile(wrapper) and os.access(wrapper, os.X_OK):
11852 return [wrapper]
11853 return [sys.executable, os.path.join(PACKAGE_PROJECT_ROOT, "picurv_cli", "picurv")]
11854
11855
11856def build_spectra_follow_command(run_dir: str, post_path: str, post_cfg: dict,
11857 recompute: bool = False) -> list:
11858 """!
11859 @brief Build the batch-script step that measures spectra after the field stage.
11860
11861 @details Spectra are a serial pass over committed checkpoints, so the command is
11862 returned bare rather than wrapped in the MPI launcher: run under `srun`
11863 with the post stage's task count it would become one identical copy per
11864 task, each writing the same files.
11865
11866 @param[in] run_dir Run directory the batch job operates on.
11867 @param[in] post_path Post recipe path, reachable from the compute node.
11868 @param[in] post_cfg Effective post configuration.
11869 @param[in] recompute Re-measure steps whose spectra are still valid.
11870 @return Argv list, or an empty list when the recipe requests no spectra.
11871 """
11872 try:
11873 spectra = normalize_post_spectra_config(post_cfg)
11874 except ValueError:
11875 # Validation reports recipe errors; do not raise a second time here.
11876 return []
11877 if not spectra["tasks"]:
11878 return []
11880 "run", "--post-process", "--only", "spectra",
11881 "--run-dir", os.path.abspath(run_dir),
11882 "--post", os.path.abspath(post_path),
11883 ] + (["--recompute"] if recompute else [])
11884
11885
11886def executable_petsc_library_dir(executable_path: str) -> "str | None":
11887 """!
11888 @brief The lib/ directory of the PETSc an executable was built against, from its stamp.
11889 @details Both PETSc index widths name their library libpetsc.so.MAJOR.MINOR, so the loader
11890 takes whichever comes first on LD_LIBRARY_PATH; launching with this directory first
11891 gives each executable its own PETSc whatever modules the job loaded.
11892 @param[in] executable_path Path to `simulator`, `postprocessor` or their 64-bit pair.
11893 @return Absolute directory, or None when the stamp does not name one.
11894 """
11895 petsc = read_binary_petsc_stamp(executable_path) if os.path.isfile(executable_path) else None
11896 if not petsc or not petsc.get("dir"):
11897 return None
11898 library_dir = os.path.join(petsc["dir"], petsc.get("arch") or "", "lib")
11899 return os.path.normpath(library_dir) if os.path.isdir(library_dir) else None
11900
11901
11902def petsc_library_path_lines(library_dir: "str | None") -> list:
11903 """!
11904 @brief Shell lines putting one PETSc's lib/ first on LD_LIBRARY_PATH, after module setup.
11905 @param[in] library_dir From `executable_petsc_library_dir()`, or None for no change.
11906 @return Script lines.
11907 """
11908 if not library_dir:
11909 return []
11910 return [f'export LD_LIBRARY_PATH={shlex.quote(library_dir)}"${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}"']
11911
11912
11913def executable_identity_check_lines(executable: str, expected_version_line: "str | None",
11914 pinned: bool = False) -> list:
11915 """!
11916 @brief Shell lines that compare an executable's reported identity with the staged one.
11917
11918 @details A job resolves its executable when it starts, which can be long after it
11919 was staged. The installation's `bin/` may have been rebuilt in between, and
11920 a job that silently runs the new build produces checkpoints that disagree
11921 with the run's manifest. The lines run after module setup, because
11922 `--version` still needs the executable's shared libraries, and before the
11923 launcher, so a mismatch stops the job before any rank starts. When staging
11924 could not read an identity there is nothing to compare, so the reported one
11925 is only logged.
11926 @param[in] executable Executable path, emitted verbatim when it is a shell variable.
11927 @param[in] expected_version_line Exact `--version` output captured at staging, or None.
11928 @param[in] pinned Whether the executable is the run's own pinned copy.
11929 @return Script lines.
11930 """
11931 token = executable if executable.startswith("$") else shlex.quote(executable)
11932 lines = [
11933 f'PICURV_EXECUTABLE_IDENTITY=$({token} --version 2>&1) || {{ '
11934 f'echo "[FATAL] {executable} --version failed: ${{PICURV_EXECUTABLE_IDENTITY}}" >&2; exit 1; }}',
11935 'echo "[$(date)] Executable identity: ${PICURV_EXECUTABLE_IDENTITY}"',
11936 ]
11937 if not expected_version_line:
11938 lines.append('echo "[$(date)] No identity was readable at staging, so none is compared."')
11939 return lines
11940 remedy = (
11941 "The run's pinned copy was replaced after staging; restore it or re-pin the run."
11942 if pinned else
11943 "It was rebuilt after staging; restage the job, or stage with --pin-executables so "
11944 "the run keeps its own copy."
11945 )
11946 lines.extend([
11947 f"PICURV_STAGED_IDENTITY={shlex.quote(expected_version_line)}",
11948 'if [ "${PICURV_EXECUTABLE_IDENTITY}" != "${PICURV_STAGED_IDENTITY}" ]; then',
11949 f' echo "[FATAL] {executable} reports ${{PICURV_EXECUTABLE_IDENTITY}}, but this job was '
11950 f'staged against ${{PICURV_STAGED_IDENTITY}}. {remedy}" >&2',
11951 " exit 1",
11952 "fi",
11953 ])
11954 return lines
11955
11956
11958 script_path: str,
11959 job_name: str,
11960 cluster_cfg: dict,
11961 command: list,
11962 workdir: str,
11963 stdout_path: str,
11964 stderr_path: str = None,
11965 env_vars: dict = None,
11966 shell_env_vars: dict = None,
11967 array_spec: str = None,
11968 follow_commands: list = None,
11969 identity_check: "tuple | None" = None,
11970):
11971 """!
11972 @brief Render a Slurm batch script for a single command.
11973 @param[in] script_path Argument passed to `render_slurm_script()`.
11974 @param[in] job_name Argument passed to `render_slurm_script()`.
11975 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
11976 @param[in] command Argument passed to `render_slurm_script()`.
11977 @param[in] workdir Argument passed to `render_slurm_script()`.
11978 @param[in] stdout_path Argument passed to `render_slurm_script()`.
11979 @param[in] stderr_path Argument passed to `render_slurm_script()`.
11980 @param[in] env_vars Argument passed to `render_slurm_script()`.
11981 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
11982 @param[in] array_spec Argument passed to `render_slurm_script()`.
11983 @param[in] follow_commands Commands to run after the launched one, each an argv
11984 list. They run in the batch shell rather than under the
11985 MPI launcher, so a serial step does not become one copy
11986 per task. Supplying any of them drops the `exec`.
11987 @param[in] identity_check `(executable, expected --version line, pinned)` checked
11988 before launch; see `executable_identity_check_lines()`.
11989 """
11990 resources = cluster_cfg.get("resources", {})
11991 notifications = cluster_cfg.get("notifications", {}) or {}
11992 execution = cluster_cfg.get("execution", {}) or {}
11993 extra_sbatch = execution.get("extra_sbatch")
11994 module_setup = execution.get("module_setup", []) or []
11995
11996 if stderr_path is None:
11997 stderr_path = stdout_path.replace(".out", ".err")
11998
11999 lines = [
12000 "#!/bin/bash",
12001 f"#SBATCH --job-name={job_name}",
12002 f"#SBATCH --nodes={resources['nodes']}",
12003 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
12004 f"#SBATCH --mem={resources['mem']}",
12005 f"#SBATCH --time={resources['time']}",
12006 f"#SBATCH --output={stdout_path}",
12007 f"#SBATCH --error={stderr_path}",
12008 f"#SBATCH --account={resources['account']}",
12009 ]
12010 partition = resources.get("partition")
12011 if partition:
12012 lines.append(f"#SBATCH --partition={partition}")
12013 if array_spec:
12014 lines.append(f"#SBATCH --array={array_spec}")
12015 mail_user = notifications.get("mail_user")
12016 mail_type = notifications.get("mail_type")
12017 if mail_user:
12018 lines.append(f"#SBATCH --mail-user={mail_user}")
12019 if mail_type:
12020 lines.append(f"#SBATCH --mail-type={mail_type}")
12021
12022 if isinstance(extra_sbatch, dict):
12023 for key, value in extra_sbatch.items():
12024 flag = str(key)
12025 if not flag.startswith("--"):
12026 flag = f"--{flag}"
12027 if isinstance(value, bool):
12028 if value:
12029 lines.append(f"#SBATCH {flag}")
12030 elif value is not None:
12031 lines.append(f"#SBATCH {flag}={value}")
12032 elif isinstance(extra_sbatch, list):
12033 for token in extra_sbatch:
12034 lines.append(f"#SBATCH {token}")
12035
12036 lines.extend(
12037 [
12038 "",
12039 "set -euo pipefail",
12040 "",
12041 f"cd {shlex.quote(workdir)}",
12042 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
12043 'echo "[$(date)] Working directory: $PWD"',
12044 ]
12045 )
12046
12047 if shell_env_vars:
12048 for key, value in shell_env_vars.items():
12049 lines.append(f"export {key}={value}")
12050
12051 for setup_line in module_setup:
12052 lines.append(str(setup_line))
12053
12054 if env_vars:
12055 for key, value in env_vars.items():
12056 lines.append(f"export {key}={shlex.quote(str(value))}")
12057
12058 if identity_check:
12059 lines.extend(petsc_library_path_lines(executable_petsc_library_dir(identity_check[0])))
12060 lines.extend(executable_identity_check_lines(*identity_check))
12061
12062 cmd = " ".join(shlex.quote(str(tok)) for tok in command)
12063 if follow_commands:
12064 # `exec` would replace the shell and the trailing commands would never run.
12065 # `set -e` is already in effect, so a failure in the launched command aborts
12066 # the job before anything downstream of it executes.
12067 lines.append(cmd)
12068 for follow in follow_commands:
12069 lines.append(" ".join(shlex.quote(str(tok)) for tok in follow))
12070 else:
12071 lines.append(f"exec {cmd}")
12072
12073 os.makedirs(os.path.dirname(script_path), exist_ok=True)
12074 with open(script_path, "w") as f:
12075 f.write("\n".join(lines) + "\n")
12076 os.chmod(script_path, 0o755)
12077
12079 launcher: "str | None",
12080 launcher_args: "list | None" = None,
12081 label: str = "launcher",
12082) -> "tuple[str | None, list[str]]":
12083 """!
12084 @brief Canonicalize launcher config into executable token plus argv-style flags.
12085 @param[in] launcher Argument passed to `split_launcher_tokens()`.
12086 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
12087 @param[in] label Argument passed to `split_launcher_tokens()`.
12088 @return Value returned by `split_launcher_tokens()`.
12089 """
12090 normalized_args = [str(x) for x in (launcher_args or [])]
12091
12092 if launcher is None:
12093 return None, normalized_args
12094
12095 try:
12096 launcher_tokens = shlex.split(str(launcher))
12097 except ValueError as exc:
12098 raise ValueError(f"{label} is not shell-parseable: {exc}") from exc
12099
12100 if not launcher_tokens:
12101 return None, normalized_args
12102
12103 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
12104
12105
12106def normalize_cluster_launcher(execution: dict) -> "tuple[str | None, list[str]]":
12107 """!
12108 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
12109 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
12110 @return Value returned by `normalize_cluster_launcher()`.
12111 """
12112 return split_launcher_tokens(
12113 execution.get("launcher"),
12114 execution.get("launcher_args") or [],
12115 label="execution.launcher",
12116 )
12117
12118
12119def strip_launcher_size_flags(launcher_name: str, launcher_args: "list[str]") -> "list[str]":
12120 """!
12121 @brief Remove explicit MPI task-count flags from known launchers.
12122 @param[in] launcher_name Basename-normalized launcher executable.
12123 @param[in] launcher_args Launcher argument list.
12124 @return Filtered launcher arguments with explicit size flags removed.
12125 """
12126 filtered = []
12127 idx = 0
12128 while idx < len(launcher_args):
12129 token = str(launcher_args[idx])
12130
12131 if launcher_name == "srun":
12132 if token in {"-n", "--ntasks"}:
12133 idx += 2
12134 continue
12135 if token.startswith("--ntasks="):
12136 idx += 1
12137 continue
12138 elif launcher_name in {"mpiexec", "mpirun"}:
12139 if token in {"-n", "-np"}:
12140 idx += 2
12141 continue
12142 if token.startswith("-n=") or token.startswith("-np="):
12143 idx += 1
12144 continue
12145
12146 filtered.append(token)
12147 idx += 1
12148
12149 return filtered
12150
12151
12153 executable: str,
12154 executable_args: list,
12155 num_procs: int,
12156 config_search_anchor: str = None,
12157 allow_single_rank_launcher_override: bool = False,
12158 force_num_procs: "int | None" = None,
12159) -> list:
12160 """!
12161 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
12162 @param[in] executable Argument passed to `build_local_launch_command()`.
12163 @param[in] executable_args Argument passed to `build_local_launch_command()`.
12164 @param[in] num_procs Argument passed to `build_local_launch_command()`.
12165 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
12166 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
12167 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
12168 @return Value returned by `build_local_launch_command()`.
12169 """
12170 target_num_procs = force_num_procs if force_num_procs is not None else num_procs
12171 command = [executable] + executable_args
12172 if target_num_procs <= 1 and not allow_single_rank_launcher_override:
12173 return command
12174
12175 launcher_override = os.environ.get("PICURV_MPI_LAUNCHER")
12176 if launcher_override is None:
12177 launcher_override = os.environ.get("MPI_LAUNCHER")
12178
12179 try:
12180 if launcher_override is not None:
12181 explicit_launcher_config = True
12182 launcher, launcher_args = split_launcher_tokens(
12183 launcher_override,
12184 label="local MPI launcher override",
12185 )
12186 else:
12187 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor)
12188 local_execution = resolve_runtime_execution_context(runtime_execution_cfg, "local")
12189 configured_launcher = local_execution.get("launcher")
12190 configured_args = local_execution.get("launcher_args") or []
12191 explicit_launcher_config = configured_launcher is not None or bool(configured_args)
12192 if target_num_procs <= 1 and not explicit_launcher_config:
12193 return command
12194 launcher, launcher_args = split_launcher_tokens(
12195 configured_launcher if configured_launcher is not None else "mpiexec",
12196 configured_args,
12197 label="local_execution.launcher",
12198 )
12199 except ValueError as exc:
12200 print(f"[FATAL] {exc}", file=sys.stderr)
12201 sys.exit(1)
12202
12203 if not launcher:
12204 return command
12205
12206 launcher_name = os.path.basename(launcher).lower()
12207 if force_num_procs is not None:
12208 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
12209 prefix = [launcher] + launcher_args
12210
12211 if launcher_name == "srun":
12212 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
12213 if not has_n:
12214 prefix += ["-n", str(target_num_procs)]
12215 elif launcher_name in {"mpiexec", "mpirun"}:
12216 has_n = any(token in {"-n", "-np"} for token in launcher_args)
12217 if not has_n:
12218 prefix += ["-n", str(target_num_procs)]
12219
12220 return prefix + command
12221
12222def restart_grid_inputs(run_dir: str, case_cfg: dict) -> dict:
12223 """!
12224 @brief The inputs a run's geometry is derived from, to compare across a restart.
12225 @details The solver compares the checkpoint's geometry, which it hashes from the built
12226 grid, only after the job has started. These inputs determine that geometry
12227 within one build, so a difference is worth saying at staging: a staged
12228 `inputs/grid/grid.run` (file and generated grids, already non-dimensional)
12229 by content, otherwise the grid section without its rank layout and the
12230 length scale; and the periodic faces of every block.
12231 @param[in] run_dir Run whose staged grid is read.
12232 @param[in] case_cfg That run's case configuration.
12233 @return Mapping of input name to a comparable value.
12234 """
12235 staged = os.path.join(run_dir, "inputs", "grid", "grid.run")
12236 grid = {key: value for key, value in (case_cfg.get("grid") or {}).items()
12237 if not str(key).startswith("da_processors_")}
12238 try:
12239 periodic = [sorted(bc["face"] for bc in block if bc["type"] == "PERIODIC")
12240 for block in validate_and_prepare_boundary_conditions(case_cfg)]
12241 except (ValueError, KeyError, TypeError):
12242 periodic = None
12243 inputs = {"periodic faces": periodic}
12244 if os.path.isfile(staged):
12245 inputs["grid file"] = _file_sha256(staged)
12246 else:
12247 inputs["grid"] = grid
12248 inputs["length_ref"] = ((case_cfg.get("properties") or {}).get("scaling") or {}).get("length_ref")
12249 return inputs
12250
12251
12252def warn_on_restart_grid_change(source_inputs: dict, branch_inputs: dict):
12253 """!
12254 @brief Warn at staging when a restart's grid inputs differ from its source run's.
12255 @param[in] source_inputs restart_grid_inputs() of the run that wrote the checkpoint.
12256 @param[in] branch_inputs restart_grid_inputs() of the staged restart.
12257 @return Names of the inputs that differ.
12258 """
12259 changed = sorted(name for name in set(source_inputs) | set(branch_inputs)
12260 if source_inputs.get(name) != branch_inputs.get(name))
12261 if changed:
12262 print(f"[WARNING] This restart's grid inputs differ from the source run's ({', '.join(changed)}). "
12263 "The solver refuses a checkpoint whose geometry differs, but only once the job has "
12264 "started and built the grid.", file=sys.stderr)
12265 return changed
12266
12267
12268def check_restart_continuation(solver_exe: str, solver_args: list, run_dir: str,
12269 config_search_anchor: str = None):
12270 """!
12271 @brief Ask the simulator whether a staged restart's carried statistics windows can continue.
12272 @details Runs the simulator's restart check on one rank. It parses the run's control
12273 file and compares each window with the source checkpoint's manifest exactly
12274 as the restart will, but builds no grid, so it costs about a second and runs
12275 on a login node whatever the production rank count. Passing it does not
12276 replace the full check at solver start, which also compares geometry.
12277 @param[in] solver_exe Simulator executable the run will launch.
12278 @param[in] solver_args Simulator arguments the run will launch with.
12279 @param[in] run_dir Staged run directory, the simulator's working directory.
12280 @param[in] config_search_anchor Path from which the local launcher configuration is found.
12281 @return None when the windows can continue, else the simulator's own reason.
12282 """
12284 solver_exe, solver_args + ["-restart_check_only", "true"], 1,
12285 config_search_anchor=config_search_anchor,
12286 allow_single_rank_launcher_override=True, force_num_procs=1,
12287 )
12288 result = _run_captured_command(command, run_dir)
12289 if result.returncode == 0:
12290 print("[INFO] Statistics continuation check passed.")
12291 return None
12292 # PETSc prints a banner, its generic error class, then the simulator's own message.
12293 messages = [line.split("PETSC ERROR:", 1)[1].strip()
12294 for line in (result.stdout + result.stderr).splitlines() if "PETSC ERROR:" in line]
12295 messages = [m for m in messages if m and not m.startswith("-")]
12296 return (messages[1] if len(messages) > 1 else messages[0] if messages
12297 else (result.stderr or result.stdout).strip() or f"exit status {result.returncode}")
12298
12299
12300def resolve_cluster_execution(cluster_cfg: dict, config_search_anchor: str = None, extra_search_anchors=None) -> dict:
12301 """!
12302 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
12303 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
12304 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
12305 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
12306 @return Value returned by `resolve_cluster_execution()`.
12307 """
12308 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor, extra_search_anchors=extra_search_anchors)
12309 shared_cluster_execution = resolve_runtime_execution_context(runtime_execution_cfg, "cluster")
12310 execution = cluster_cfg.get("execution", {}) or {}
12311 cluster_override = {
12312 "launcher": execution.get("launcher") if "launcher" in execution else None,
12313 "launcher_args": execution.get("launcher_args") if "launcher_args" in execution else None,
12314 }
12315 return merge_execution_overrides(shared_cluster_execution, cluster_override)
12316
12317
12319 cluster_cfg: dict,
12320 executable: str,
12321 executable_args: list,
12322 config_search_anchor: str = None,
12323 extra_search_anchors=None,
12324 force_num_procs: "int | None" = None,
12325) -> list:
12326 """!
12327 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
12328 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
12329 @param[in] executable Argument passed to `build_cluster_launch_command()`.
12330 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
12331 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
12332 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
12333 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
12334 @return Value returned by `build_cluster_launch_command()`.
12335 """
12336 try:
12337 execution = resolve_cluster_execution(
12338 cluster_cfg,
12339 config_search_anchor=config_search_anchor,
12340 extra_search_anchors=extra_search_anchors,
12341 )
12342 launcher, launcher_args = split_launcher_tokens(
12343 execution.get("launcher") if execution.get("launcher") is not None else "srun",
12344 execution.get("launcher_args") or [],
12345 label="cluster execution launcher",
12346 )
12347 except ValueError as exc:
12348 print(f"[FATAL] {exc}", file=sys.stderr)
12349 sys.exit(1)
12350
12351 ntasks = int(force_num_procs) if force_num_procs is not None else get_cluster_total_tasks(cluster_cfg)
12352 launcher_name = launcher.lower() if launcher else ""
12353 if force_num_procs is not None:
12354 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
12355
12356 if launcher and launcher_name == "srun":
12357 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
12358 cmd = ["srun"] + launcher_args
12359 if not has_n:
12360 cmd += ["-n", str(ntasks)]
12361 return cmd + [executable] + executable_args
12362
12363 if launcher and launcher_name == "mpirun":
12364 has_np = any(token in {"-np", "-n"} for token in launcher_args)
12365 cmd = ["mpirun"] + launcher_args
12366 if not has_np:
12367 cmd += ["-np", str(ntasks)]
12368 return cmd + [executable] + executable_args
12369
12370 if launcher and launcher_name == "mpiexec":
12371 has_np = any(token in {"-np", "-n"} for token in launcher_args)
12372 cmd = ["mpiexec"] + launcher_args
12373 if not has_np:
12374 cmd += ["-np", str(ntasks)]
12375 return cmd + [executable] + executable_args
12376
12377 # Custom launcher or no launcher.
12378 cmd = []
12379 if launcher:
12380 cmd.append(str(launcher))
12381 cmd += launcher_args
12382 cmd += [executable] + executable_args
12383 return cmd
12384
12385def parse_slurm_job_id(sbatch_output: str) -> str:
12386 """!
12387 @brief Extract numeric job id from standard sbatch output.
12388 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
12389 @return Value returned by `parse_slurm_job_id()`.
12390 """
12391 match = re.search(r"Submitted batch job\s+(\d+)", sbatch_output or "")
12392 return match.group(1) if match else None
12393
12394#: Slurm end states after which an `afterok` dependency can never be satisfied.
12395_SLURM_FAILED_END_STATES = {
12396 "FAILED", "CANCELLED", "TIMEOUT", "OUT_OF_MEMORY", "NODE_FAIL", "PREEMPTED",
12397 "BOOT_FAIL", "DEADLINE",
12398}
12399
12400
12401def slurm_job_end_state(job_id: str) -> "tuple[str, str | None]":
12402 """!
12403 @brief Whether a recorded Slurm job (or array) is still queued, and how it ended if not.
12404 @details Slurm forgets a finished job after its MinJobAge, and a dependency on a job it
12405 no longer knows is rejected, so a stage submitted long after the job it waits
12406 for must not depend on it. `squeue` answers for live jobs; `sacct` for ended ones.
12407 @param[in] job_id Recorded job id; for an array, the array's id covers every task.
12408 @return `("active", state)`, `("completed", "COMPLETED")`, `("failed", state)`,
12409 `("unknown", state-or-None)` when the job is not queued but `sacct` cannot say how
12410 it ended, or `("unchecked", None)` where `squeue` is unavailable.
12411 """
12412 squeue = shutil.which("squeue")
12413 if not squeue:
12414 return "unchecked", None
12415 result = subprocess.run([squeue, "-h", "-j", str(job_id), "-o", "%T"],
12416 text=True, capture_output=True, check=False)
12417 live = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
12418 if result.returncode == 0 and live:
12419 return "active", live[0]
12420 sacct = shutil.which("sacct")
12421 if not sacct:
12422 return "unknown", None
12423 result = subprocess.run([sacct, "-n", "-X", "-P", "-j", str(job_id), "-o", "State"],
12424 text=True, capture_output=True, check=False)
12425 # "CANCELLED by 123" carries the canceller; the state is the first word.
12426 states = [line.split()[0] for line in (result.stdout or "").splitlines() if line.strip()]
12427 if result.returncode != 0 or not states:
12428 return "unknown", None
12429 failed = [state for state in states if state in _SLURM_FAILED_END_STATES]
12430 if failed:
12431 return "failed", failed[0]
12432 if all(state == "COMPLETED" for state in states):
12433 return "completed", "COMPLETED"
12434 # A state sacct reports for a job squeue no longer lists, such as a transient one.
12435 return "unknown", states[0]
12436
12437
12438def submit_sbatch(script_path: str, dependency: str = None, dependency_type: str = "afterok") -> dict:
12439 """!
12440 @brief Submit sbatch script and return submission metadata.
12441 @param[in] script_path Argument passed to `submit_sbatch()`.
12442 @param[in] dependency Argument passed to `submit_sbatch()`.
12443 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
12444 @return Value returned by `submit_sbatch()`.
12445 """
12446 cmd = ["sbatch"]
12447 if dependency:
12448 cmd.append(f"--dependency={dependency_type}:{dependency}")
12449 cmd.append(script_path)
12450 result = subprocess.run(cmd, text=True, capture_output=True, check=False)
12451 metadata = {
12452 "command": cmd,
12453 "returncode": result.returncode,
12454 "stdout": (result.stdout or "").strip(),
12455 "stderr": (result.stderr or "").strip(),
12456 "script": script_path,
12457 }
12458 if result.returncode != 0:
12459 print(f"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
12460 sys.exit(result.returncode)
12461 metadata["job_id"] = parse_slurm_job_id(metadata["stdout"])
12462 if not metadata["job_id"]:
12463 print(
12464 f"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
12465 file=sys.stderr
12466 )
12467 sys.exit(1)
12468 return metadata
12469
12470
12472 """!
12473 @brief Prints validation errors and exits.
12474 @param[in] errors List of error message strings.
12475 """
12476 print(f"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
12477 for raw_error in errors:
12478 file_path, message = _split_error_file_and_message(raw_error)
12479 key_path = _extract_key_path(message)
12480 code = _classify_error_code(message)
12481 emit_structured_error(code, key=key_path, file_path=file_path, message=message)
12482 print(
12483 "\nHint: See examples/master_template/ for valid config structure and "
12484 "docs/pages/14_Config_Contract.md for key-level contract details.",
12485 file=sys.stderr,
12486 )
12487 sys.exit(1)
12488
12489
12490def generate_header(run_id: str, source_files: dict) -> str:
12491 """!
12492 @brief Creates a standard header block for all generated files.
12493 @param[in] run_id The unique identifier for the current simulation run.
12494 @param[in] source_files A dictionary of source profile files used.
12495 @return A formatted string containing the header.
12496 """
12497 header_parts = [
12498 "# ==============================================================================",
12499 "# AUTO-GENERATED CONFIGURATION FILE",
12500 "# ------------------------------------------------------------------------------",
12501 f"# Run ID: {run_id}",
12502 f"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
12503 "#",
12504 "# Source Configuration:"
12505 ]
12506 for name, path in source_files.items():
12507 header_parts.append(f"# - {name:<12}: {os.path.basename(path)}")
12508 header_parts.extend([
12509 "#",
12510 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
12511 "# ==============================================================================\n"
12512 ])
12513 return "\n".join(header_parts)
12514
12515def generate_simple_list_file(run_dir: str, run_id: str, cfg: dict, section: str, key: str,
12516 filename: str, header_sources: dict,
12517 config_dir: str = None) -> str:
12518 """!
12519 @brief Generic function to create a file containing a simple list of strings.
12520 @param[in] run_dir The path to the main run directory.
12521 @param[in] run_id The unique identifier for the run.
12522 @param[in] cfg The dictionary containing the configuration data.
12523 @param[in] section The top-level key in the cfg dictionary.
12524 @param[in] key The second-level key whose value is the list of strings.
12525 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
12526 @param[in] header_sources A dictionary of source files for the header.
12527 @param[in] config_dir Optional configuration revision directory.
12528 @return The absolute path to the generated file.
12529 """
12530 print(f"[INFO] Generating {filename}...")
12531 config_dir = config_dir or os.path.join(run_dir, "config")
12532 os.makedirs(config_dir, exist_ok=True)
12533 file_path = os.path.join(config_dir, filename)
12534
12535 lines = [generate_header(run_id, header_sources)]
12536 items = cfg.get(section, {}).get(key, [])
12537 lines.extend(items)
12538
12539 with open(file_path, "w") as f: f.write("\n".join(lines))
12540 print(f"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
12541 return os.path.abspath(file_path)
12542
12543
12544def normalize_monitor_function_names(names: list, field_name: str) -> list:
12545 """!
12546 @brief Translate renamed solver functions in a monitor.yml function list.
12547 @param[in] names Function names as written in monitor.yml.
12548 @param[in] field_name YAML path of the list, used in warnings.
12549 @return The list with renamed functions replaced and removed ones dropped.
12550 @details Logging and profiling select functions by exact name, so a stale name
12551 silently selects nothing. Renamed functions are mapped to their current
12552 names and removed ones dropped, each with a warning.
12553 """
12554 normalized = []
12555 for name in names or []:
12556 if name in RENAMED_MONITOR_FUNCTIONS:
12557 current = RENAMED_MONITOR_FUNCTIONS[name]
12558 print(f"[WARNING] {field_name}: '{name}' was renamed to '{current}'; using '{current}'.",
12559 file=sys.stderr)
12560 name = current
12561 elif name in REMOVED_MONITOR_FUNCTIONS:
12562 print(f"[WARNING] {field_name}: '{name}' no longer exists (removed by the Poisson "
12563 "rewrite, 0d34592); dropping it.", file=sys.stderr)
12564 continue
12565 if name not in normalized:
12566 normalized.append(name)
12567 return normalized
12568
12569
12570def has_explicit_monitor_whitelist(monitor_cfg: dict) -> bool:
12571 """!
12572 @brief Return True when logging.enabled_functions contains at least one entry.
12573 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
12574 @return Value returned by `has_explicit_monitor_whitelist()`.
12575 """
12576 items = monitor_cfg.get("logging", {}).get("enabled_functions", [])
12577 return bool(items)
12578
12579
12580#: Fixed names of the solver records under a run's `logs/`.
12581RUNTIME_MEMORY_FILE = "Runtime_Memory.csv"
12582PROFILING_TIMESTEP_FILE = "Profiling_Timestep_Summary.csv"
12583
12584
12585def resolve_profiling_config(monitor_cfg: dict) -> dict:
12586 """!
12587 @brief Resolve profiling reporting config from monitor.yml.
12588 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
12589 @return Value returned by `resolve_profiling_config()`.
12590 """
12591 profiling_cfg = monitor_cfg.get("profiling", {}) or {}
12592 timestep_cfg = profiling_cfg.get("timestep_output")
12593 final_cfg = profiling_cfg.get("final_summary")
12594
12595 if timestep_cfg is None:
12596 mode = "off"
12597 functions = []
12598 else:
12599 if not isinstance(timestep_cfg, dict):
12600 raise ValueError("monitor.profiling.timestep_output must be a mapping when provided.")
12601 mode = str(timestep_cfg.get("mode", "off")).lower()
12602 functions = timestep_cfg.get("functions", [])
12603
12604 if mode not in PROFILING_TIMESTEP_MODES:
12605 raise ValueError("monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
12606 if functions is None:
12607 functions = []
12608 if not isinstance(functions, list):
12609 raise ValueError("monitor.profiling.timestep_output.functions must be a list of function names.")
12610 if not all(isinstance(item, str) and item.strip() for item in functions):
12611 raise ValueError("monitor.profiling.timestep_output.functions entries must be non-empty strings.")
12612 if mode == "selected" and not functions:
12613 raise ValueError("monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
12614 if mode != "selected" and functions:
12615 raise ValueError("monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
12616 functions = normalize_monitor_function_names(functions, "monitor.profiling.timestep_output.functions")
12617 if mode == "selected" and not functions:
12618 raise ValueError("monitor.profiling.timestep_output.functions lists only functions that no longer exist.")
12619
12620 if final_cfg is None:
12621 final_enabled = True
12622 elif isinstance(final_cfg, dict):
12623 final_enabled = bool(final_cfg.get("enabled", True))
12624 else:
12625 raise ValueError("monitor.profiling.final_summary must be a mapping when provided.")
12626
12627 # Per-particle and per-cell helper timers are off unless named here, or 'all'.
12628 helpers = profiling_cfg.get("helpers", [])
12629 if helpers is None:
12630 helpers = []
12631 if isinstance(helpers, str):
12632 if helpers.strip().lower() != "all":
12633 raise ValueError("monitor.profiling.helpers must be 'all' or a list of function names.")
12634 helpers = ["all"]
12635 elif not isinstance(helpers, list) or not all(isinstance(item, str) and item.strip() for item in helpers):
12636 raise ValueError("monitor.profiling.helpers must be 'all' or a list of non-empty function names.")
12637 elif helpers != ["all"]:
12638 helpers = normalize_monitor_function_names(helpers, "monitor.profiling.helpers")
12639 if any("," in item or item.strip() != item for item in helpers):
12640 raise ValueError("monitor.profiling.helpers names must not contain commas or surrounding spaces.")
12641
12642 return {
12643 "mode": mode,
12644 "functions": functions,
12645 "final_summary_enabled": final_enabled,
12646 "helpers": helpers,
12647 }
12648
12649
12650DIAGNOSTICS_PETSC_KEYS = {
12651 "info",
12652 "malloc_debug",
12653 "malloc_test",
12654 "malloc_dump",
12655 "malloc_view",
12656 "malloc_view_threshold",
12657 "memory_view",
12658 "log_view",
12659 "log_view_memory",
12660 "log_all",
12661 "log_trace",
12662 "objects_dump",
12663 "options_left",
12664}
12665
12666
12667def _diagnostic_info(value) -> dict:
12668 """!
12669 @brief Validate PETSc info logging configuration.
12670 @param[in] value Boolean or structured PETSc info configuration.
12671 @return Normalized enabled/class-filter mapping.
12672 """
12673 if isinstance(value, bool):
12674 return {"enabled": value, "classes": []}
12675 if value is None:
12676 return {"enabled": False, "classes": []}
12677 if not isinstance(value, dict):
12678 raise ValueError("monitor.diagnostics.petsc.info must be boolean, null, or a mapping.")
12679 unknown = sorted(set(value) - {"enabled", "classes"})
12680 if unknown:
12681 raise ValueError(f"monitor.diagnostics.petsc.info has unsupported key(s): {unknown}.")
12682 enabled = value.get("enabled", True)
12683 classes = value.get("classes", [])
12684 if not isinstance(enabled, bool):
12685 raise ValueError("monitor.diagnostics.petsc.info.enabled must be boolean.")
12686 if not isinstance(classes, list) or not all(
12687 isinstance(item, str) and item.strip() and "," not in item and ":" not in item
12688 for item in classes
12689 ):
12690 raise ValueError(
12691 "monitor.diagnostics.petsc.info.classes must be a list of non-empty PETSc class names."
12692 )
12693 return {"enabled": enabled, "classes": [item.strip() for item in classes]}
12694
12695
12696def _diagnostic_bool_or_path(value, key: str):
12697 """!
12698 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
12699 @param[in] value Candidate value.
12700 @param[in] key Diagnostics key used in error messages.
12701 @return Normalized value.
12702 """
12703 if isinstance(value, bool) or value is None:
12704 return value
12705 if isinstance(value, str) and value.strip():
12706 return value.strip()
12707 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
12708
12709
12710def _diagnostic_bool(value, key: str) -> bool:
12711 """!
12712 @brief Validate a diagnostics boolean value.
12713 @param[in] value Candidate value.
12714 @param[in] key Diagnostics key used in error messages.
12715 @return Boolean value.
12716 """
12717 if isinstance(value, bool):
12718 return value
12719 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean.")
12720
12721
12722def _diagnostic_bool_or_all(value, key: str):
12723 """!
12724 @brief Validate a diagnostics value that can be false, true, or "all".
12725 @param[in] value Candidate value.
12726 @param[in] key Diagnostics key used in error messages.
12727 @return Normalized value.
12728 """
12729 if isinstance(value, bool) or value is None:
12730 return value
12731 if isinstance(value, str) and value.strip().lower() == "all":
12732 return "all"
12733 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
12734
12735
12736def _diagnostic_default_file(run_dir: str, filename: str) -> str:
12737 """!
12738 @brief Return an absolute run-local diagnostics file path.
12739 @param[in] run_dir Run directory.
12740 @param[in] filename Diagnostics filename.
12741 @return Absolute diagnostics path under the run logs directory.
12742 """
12743 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"], filename))
12744
12745
12746def _diagnostic_resolve_path_or_default(value, run_dir: str, default_filename: str):
12747 """!
12748 @brief Resolve true/string diagnostics values to a concrete file path.
12749 @param[in] value Boolean/string diagnostics value.
12750 @param[in] run_dir Run directory.
12751 @param[in] default_filename Default file name when value is true.
12752 @return False, or an absolute/explicit path string.
12753 """
12754 if value is True:
12755 return _diagnostic_default_file(run_dir, default_filename)
12756 if isinstance(value, str):
12757 if os.path.isabs(value) or value.startswith(":"):
12758 return value
12759 return os.path.abspath(os.path.join(run_dir, "logs", value))
12760 return False
12761
12762
12763def resolve_diagnostics_config(monitor_cfg: dict, run_dir: "str | None" = None, stage_label: str = "Solver") -> dict:
12764 """!
12765 @brief Resolve monitor diagnostics config and default run-local log paths.
12766 @param[in] monitor_cfg Parsed monitor.yml mapping.
12767 @param[in] run_dir Optional run directory for default artifact paths.
12768 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
12769 @return Normalized diagnostics config.
12770 """
12771 diagnostics_cfg = (monitor_cfg.get("diagnostics", {}) or {}) if isinstance(monitor_cfg, dict) else {}
12772 if not isinstance(diagnostics_cfg, dict):
12773 raise ValueError("monitor.diagnostics must be a mapping when provided.")
12774
12775 petsc_raw = diagnostics_cfg.get("petsc", {}) or {}
12776 if not isinstance(petsc_raw, dict):
12777 raise ValueError("monitor.diagnostics.petsc must be a mapping when provided.")
12778 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
12779 if unknown:
12780 raise ValueError(f"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
12781
12782 petsc = {
12783 "info": _diagnostic_info(petsc_raw.get("info", False)),
12784 "malloc_debug": _diagnostic_bool(petsc_raw.get("malloc_debug", False), "malloc_debug"),
12785 "malloc_test": _diagnostic_bool(petsc_raw.get("malloc_test", False), "malloc_test"),
12786 "malloc_dump": _diagnostic_bool(petsc_raw.get("malloc_dump", False), "malloc_dump"),
12787 "malloc_view": _diagnostic_bool_or_path(petsc_raw.get("malloc_view", False), "malloc_view"),
12788 "malloc_view_threshold": petsc_raw.get("malloc_view_threshold"),
12789 "memory_view": _diagnostic_bool(petsc_raw.get("memory_view", False), "memory_view"),
12790 "log_view": _diagnostic_bool_or_path(petsc_raw.get("log_view", False), "log_view"),
12791 "log_view_memory": _diagnostic_bool(petsc_raw.get("log_view_memory", False), "log_view_memory"),
12792 "log_all": _diagnostic_bool(petsc_raw.get("log_all", False), "log_all"),
12793 "log_trace": _diagnostic_bool_or_path(petsc_raw.get("log_trace", False), "log_trace"),
12794 "objects_dump": _diagnostic_bool_or_all(petsc_raw.get("objects_dump", False), "objects_dump"),
12795 "options_left": petsc_raw.get("options_left"),
12796 }
12797 if petsc["malloc_view_threshold"] is not None and not isinstance(petsc["malloc_view_threshold"], (int, float)):
12798 raise ValueError("monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
12799 if petsc["options_left"] is not None and not isinstance(petsc["options_left"], bool):
12800 raise ValueError("monitor.diagnostics.petsc.options_left must be boolean or null.")
12801
12802 memory_raw = diagnostics_cfg.get("runtime_memory_log", {}) or {}
12803 if not isinstance(memory_raw, dict):
12804 raise ValueError("monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
12805 # `file` is refused by the monitor schema; a stored run's config may still carry it.
12806 memory_unknown = sorted(set(memory_raw.keys()) - {"enabled", "file"})
12807 if memory_unknown:
12808 raise ValueError(f"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
12809 memory_enabled = memory_raw.get("enabled", True)
12810 if not isinstance(memory_enabled, bool):
12811 raise ValueError("monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
12812
12813 resolved_petsc = dict(petsc)
12814 artifacts = []
12815 if run_dir:
12816 suffix = "PostProcessor" if stage_label == "PostProcessor" else "Solver"
12817 resolved_petsc["info"] = False
12818 defaults = {
12819 "malloc_view": f"PETSc_MallocView_{suffix}.log",
12820 "log_view": f"PETSc_LogView_{suffix}.log",
12821 "log_trace": f"PETSc_LogTrace_{suffix}.log",
12822 }
12823 if petsc["info"]["enabled"]:
12824 info_path = _diagnostic_default_file(run_dir, f"PETSc_Info_{suffix}.log")
12825 classes = petsc["info"]["classes"]
12826 resolved_petsc["info"] = info_path + (f":{','.join(classes)}" if classes else "")
12827 # PetscInfoSetFile appends the emitting MPI rank (for example `.0`).
12828 artifacts.append(f"{info_path}.*")
12829 for key, default_name in defaults.items():
12830 resolved_value = _diagnostic_resolve_path_or_default(petsc[key], run_dir, default_name)
12831 if key == "log_view" and resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
12832 resolved_value = f":{resolved_value}"
12833 resolved_petsc[key] = resolved_value
12834 if resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
12835 artifacts.append(resolved_value)
12836 elif resolved_value and isinstance(resolved_value, str) and resolved_value.startswith(":"):
12837 artifacts.append(resolved_value[1:])
12838 if memory_enabled:
12839 artifacts.append(
12840 os.path.abspath(os.path.join(
12841 run_dir, CANONICAL_RUN_PATHS["logs"], RUNTIME_MEMORY_FILE
12842 ))
12843 )
12844
12845 return {
12846 "petsc": resolved_petsc,
12847 "runtime_memory_log": {"enabled": memory_enabled},
12848 "artifacts": artifacts,
12849 }
12850
12851
12852def build_petsc_diagnostics_args(monitor_cfg: dict, run_dir: str, stage_label: str) -> list:
12853 """!
12854 @brief Build PETSc diagnostics command-line arguments for a run stage.
12855 @param[in] monitor_cfg Parsed monitor.yml mapping.
12856 @param[in] run_dir Run directory used to resolve default diagnostics files.
12857 @param[in] stage_label Stage label for default output names.
12858 @return List of executable arguments.
12859 """
12860 diagnostics = resolve_diagnostics_config(monitor_cfg, run_dir, stage_label)
12861 petsc = diagnostics["petsc"]
12862 args = []
12863 if petsc["info"]:
12864 args.extend(["-info", str(petsc["info"])])
12865 if petsc["malloc_debug"]:
12866 args.append("-malloc_debug")
12867 if petsc["malloc_test"]:
12868 args.append("-malloc_test")
12869 for key, flag in (
12870 ("malloc_dump", "-malloc_dump"),
12871 ("malloc_view", "-malloc_view"),
12872 ("memory_view", "-memory_view"),
12873 ("log_view", "-log_view"),
12874 ("log_trace", "-log_trace"),
12875 ("objects_dump", "-objects_dump"),
12876 ):
12877 value = petsc.get(key)
12878 if value is True:
12879 args.append(flag)
12880 elif value:
12881 args.extend([flag, str(value)])
12882 if petsc["malloc_view_threshold"] is not None:
12883 args.extend(["-malloc_view_threshold", str(petsc["malloc_view_threshold"])])
12884 if petsc["log_view_memory"]:
12885 args.append("-log_view_memory")
12886 if petsc["log_all"]:
12887 args.append("-log_all")
12888 if petsc["options_left"] is not None:
12889 args.extend(["-options_left", "true" if petsc["options_left"] else "false"])
12890 elif stage_label == "PostProcessor":
12891 # The postprocessor reads the solver's control file, so every solver-only option
12892 # would be reported as unused on each run; left unset, the report is turned off.
12893 args.extend(["-options_left", "false"])
12894 return args
12895
12896
12897def _statistics_subsystem_available(case_cfg: dict, requirement) -> bool:
12898 """!
12899 @brief Report whether the subsystem a statistics field depends on is active.
12900 @param[in] case_cfg Parsed case configuration.
12901 @param[in] requirement Subsystem key from `STATISTICS_ELIGIBLE_FIELDS`, or None.
12902 @return Value returned by `_statistics_subsystem_available()`.
12903 """
12904 if requirement is None:
12905 return True
12906 physics = ((case_cfg or {}).get("models", {}) or {}).get("physics", {}) or {}
12907 if requirement == "particles":
12908 particles = physics.get("particles", {}) or {}
12909 try:
12910 return int(particles.get("count", 0) or 0) > 0
12911 except (TypeError, ValueError):
12912 return False
12913 turbulence = physics.get("turbulence", {}) or {}
12914 les_on = bool((turbulence.get("les", {}) or {}).get("enabled", False))
12915 if requirement == "les":
12916 return les_on
12917 if requirement == "turbulence":
12918 return les_on
12919 return False
12920
12921
12922def normalize_field_statistics_config(monitor_cfg: dict, case_cfg: dict = None) -> dict:
12923 """!
12924 @brief Validate and canonicalize the field-statistics block of monitor.yml.
12925
12926 @details Rejects every condition the field-statistics contract forbids, naming the
12927 offending window so a message points at one entry rather than the block.
12928 Returns a canonical form the flag resolver serializes without further
12929 interpretation, so validation and emission cannot disagree.
12930
12931 @param[in] monitor_cfg Parsed monitor configuration.
12932 @param[in] case_cfg Parsed case configuration, used to check that each field's
12933 subsystem is active. Subsystem checks are skipped when None.
12934 @return Value returned by `normalize_field_statistics_config()`.
12935 """
12936 raw = (monitor_cfg or {}).get("field_statistics")
12937 if raw is None:
12938 return {"enabled": False, "windows": []}
12939 if not isinstance(raw, dict):
12940 raise ValueError("'field_statistics' must be a mapping.")
12941
12942 enabled = raw.get("enabled", False)
12943 if not isinstance(enabled, bool):
12944 raise ValueError("'field_statistics.enabled' must be true or false.")
12945 windows_raw = raw.get("windows", []) or []
12946 if not isinstance(windows_raw, list):
12947 raise ValueError("'field_statistics.windows' must be a list.")
12948 if enabled and not windows_raw:
12949 raise ValueError("'field_statistics.enabled' is true but no window is defined.")
12950
12951 windows = []
12952 seen_names = set()
12953 for index, window in enumerate(windows_raw):
12954 if not isinstance(window, dict):
12955 raise ValueError(f"'field_statistics.windows[{index}]' must be a mapping.")
12956 name = window.get("name")
12957 if not isinstance(name, str) or not name.strip():
12958 raise ValueError(f"'field_statistics.windows[{index}]' needs a non-empty 'name'.")
12959 name = name.strip()
12960 # Window names identify saved state across a restart, so a duplicate would
12961 # make two windows indistinguishable in the checkpoint.
12962 if name in seen_names:
12963 raise ValueError(f"field statistics window '{name}' is defined more than once.")
12964 seen_names.add(name)
12965
12966 start_time = window.get("start_time")
12967 if not isinstance(start_time, (int, float)) or isinstance(start_time, bool):
12968 raise ValueError(f"field statistics window '{name}': 'start_time' must be a number.")
12969 end_time = window.get("end_time")
12970 if end_time is not None:
12971 if not isinstance(end_time, (int, float)) or isinstance(end_time, bool):
12972 raise ValueError(f"field statistics window '{name}': 'end_time' must be a number.")
12973 if float(end_time) <= float(start_time):
12974 raise ValueError(
12975 f"field statistics window '{name}': 'end_time' ({end_time}) must be greater "
12976 f"than 'start_time' ({start_time})."
12977 )
12978
12979 weighting = window.get("weighting")
12980 if weighting not in STATISTICS_WEIGHTING_MODES:
12981 raise ValueError(
12982 f"field statistics window '{name}': 'weighting' must be one of "
12983 f"{list(STATISTICS_WEIGHTING_MODES)} (got {weighting!r})."
12984 )
12985
12986 has_step = "step_cadence" in window and window["step_cadence"] is not None
12987 has_time = "time_cadence" in window and window["time_cadence"] is not None
12988 if has_step == has_time:
12989 raise ValueError(
12990 f"field statistics window '{name}': set exactly one of 'step_cadence' and "
12991 "'time_cadence'."
12992 )
12993 step_cadence = None
12994 time_cadence = None
12995 if has_step:
12996 step_cadence = window["step_cadence"]
12997 if not isinstance(step_cadence, int) or isinstance(step_cadence, bool) or step_cadence <= 0:
12998 raise ValueError(
12999 f"field statistics window '{name}': 'step_cadence' must be a positive integer "
13000 f"(got {step_cadence!r})."
13001 )
13002 else:
13003 time_cadence = window["time_cadence"]
13004 if (not isinstance(time_cadence, (int, float)) or isinstance(time_cadence, bool)
13005 or float(time_cadence) <= 0.0):
13006 raise ValueError(
13007 f"field statistics window '{name}': 'time_cadence' must be a positive number "
13008 f"(got {time_cadence!r})."
13009 )
13010
13011 fields, covariances = _normalize_statistics_requests(
13012 f"field statistics window '{name}'", window.get("fields"), window.get("covariances"),
13013 STATISTICS_ELIGIBLE_FIELDS, case_cfg)
13014
13015 windows.append({
13016 "name": name,
13017 "start_time": float(start_time),
13018 "end_time": None if end_time is None else float(end_time),
13019 "weighting": weighting,
13020 "step_cadence": step_cadence,
13021 "time_cadence": None if time_cadence is None else float(time_cadence),
13022 "fields": fields,
13023 "covariances": covariances,
13024 })
13025
13026 return {"enabled": bool(enabled), "windows": windows}
13027
13028
13029def _normalize_statistics_requests(label: str, fields_raw, covariances_raw, eligible: dict, case_cfg=None):
13030 """!
13031 @brief Validate and canonicalize the fields and covariances one window or ensemble requests.
13032 @param[in] label Owner named in error messages, such as `field statistics window 'w'`.
13033 @param[in] fields_raw The requested fields list of the owner.
13034 @param[in] covariances_raw The requested covariance pairs of the owner, or None.
13035 @param[in] eligible Fields the owner may name, with component counts and requirements.
13036 @param[in] case_cfg Parsed case configuration for subsystem checks, or None to skip them.
13037 @return Tuple of the canonical field entries and covariance pairs.
13038 """
13039 if not isinstance(fields_raw, list) or not fields_raw:
13040 raise ValueError(f"{label}: 'fields' must be a non-empty list.")
13041 fields = []
13042 for field_entry in fields_raw:
13043 if not isinstance(field_entry, dict):
13044 raise ValueError(f"{label}: each 'fields' entry must be a mapping.")
13045 field_name = field_entry.get("field")
13046 if not isinstance(field_name, str) or field_name not in eligible:
13047 raise ValueError(
13048 f"{label}: field {field_name!r} cannot be accumulated. "
13049 f"Available fields: {sorted(eligible)}."
13050 )
13051 requirement = eligible[field_name]["requires"]
13052 if case_cfg is not None and not _statistics_subsystem_available(case_cfg, requirement):
13053 raise ValueError(
13054 f"{label}: field '{field_name}' requires the "
13055 f"'{requirement}' subsystem, which is not enabled for this case."
13056 )
13057 if any(existing["field"] == field_name for existing in fields):
13058 raise ValueError(
13059 f"{label}: field '{field_name}' is listed more than once."
13060 )
13061 moments = field_entry.get("moments")
13062 if not isinstance(moments, list) or not moments:
13063 raise ValueError(
13064 f"{label}: field '{field_name}' needs a non-empty "
13065 "'moments' list."
13066 )
13067 unknown = [m for m in moments if m not in STATISTICS_MOMENT_NAMES]
13068 if unknown:
13069 raise ValueError(
13070 f"{label}: field '{field_name}' requests unknown "
13071 f"moments {unknown}. Available moments: {list(STATISTICS_MOMENT_NAMES)}."
13072 )
13073 # The first moment is always kept, because every centered product is
13074 # measured against it.
13075 fields.append({"field": field_name, "moments": ["first"] + (["second"] if "second" in moments else [])})
13076
13077 covariances_raw = covariances_raw or []
13078 if not isinstance(covariances_raw, list):
13079 raise ValueError(f"{label}: 'covariances' must be a list.")
13080 requested = {entry["field"] for entry in fields}
13081 covariances = []
13082 for pair in covariances_raw:
13083 if not isinstance(pair, list) or len(pair) != 2:
13084 raise ValueError(
13085 f"{label}: each covariance must be a pair of field names."
13086 )
13087 first, second = pair
13088 for member in (first, second):
13089 if not isinstance(member, str) or member not in eligible:
13090 raise ValueError(
13091 f"{label}: covariance member {member!r} cannot be "
13092 f"accumulated. Available fields: {sorted(eligible)}."
13093 )
13094 if first == second:
13095 raise ValueError(
13096 f"{label}: covariance ['{first}', '{second}'] pairs a "
13097 "field with itself; request that through moments: [second] instead."
13098 )
13099 missing = sorted({first, second} - requested)
13100 if missing:
13101 raise ValueError(
13102 f"{label}: covariance ['{first}', '{second}'] needs "
13103 f"{missing} in 'fields' as well, because a co-moment is centered against their means."
13104 )
13105 # Two three-component fields would need a full nine-component tensor,
13106 # which nothing allocates.
13107 if (eligible[first]["components"] == 3
13108 and eligible[second]["components"] == 3):
13109 raise ValueError(
13110 f"{label}: covariance ['{first}', '{second}'] pairs two "
13111 "vector fields, which is not supported."
13112 )
13113 if sorted((first, second)) in [sorted(existing) for existing in covariances]:
13114 raise ValueError(
13115 f"{label}: covariance ['{first}', '{second}'] is "
13116 "requested more than once."
13117 )
13118 covariances.append([first, second])
13119
13120 return fields, covariances
13121
13122
13123def resolve_field_statistics_flags(monitor_cfg: dict, case_cfg: dict = None) -> list:
13124 """!
13125 @brief Serialize field-statistics configuration into control-file option lines.
13126 @details A window list is variable arity, so its option names are constructed from
13127 the index. Each name belongs to a family declared in the ingress audit
13128 manifest.
13129 @param[in] monitor_cfg Parsed monitor configuration.
13130 @param[in] case_cfg Parsed case configuration, for subsystem availability checks.
13131 @return Value returned by `resolve_field_statistics_flags()`.
13132 """
13133 config = normalize_field_statistics_config(monitor_cfg, case_cfg)
13134 if not config["enabled"]:
13135 return []
13136
13137 # Window times are physical; the runtime compares them against its own clock, which
13138 # is in solver time, so each is divided by the reference time L_ref / U_ref.
13139 scales = resolve_reference_scales(case_cfg or {})
13140 window_path = ("monitor", "field_statistics", "windows", "[]")
13141
13142 def solver_time(key: str, value: float) -> float:
13143 """!
13144 @brief Convert one physical window time to solver time.
13145 @param[in] key Window key, for the recorded dimension.
13146 @param[in] value Physical time.
13147 @return Solver time.
13148 """
13149 return to_solver_units(value, window_path + (key,), scales)
13150
13151 lines = ["-field_statistics_enabled true",
13152 f"-field_statistics_window_count {len(config['windows'])}"]
13153 for index, window in enumerate(config["windows"]):
13154 prefix = f"-field_statistics_window_{index}"
13155 lines.append(f"{prefix}_name {window['name']}")
13156 lines.append(f"{prefix}_start_time {solver_time('start_time', window['start_time'])!r}")
13157 # An absent end time is what makes a window open ended, so the option is
13158 # omitted rather than given a sentinel value.
13159 if window["end_time"] is not None:
13160 lines.append(f"{prefix}_end_time {solver_time('end_time', window['end_time'])!r}")
13161 lines.append(f"{prefix}_weighting {window['weighting']}")
13162 if window["step_cadence"] is not None:
13163 lines.append(f"{prefix}_step_cadence {window['step_cadence']}")
13164 else:
13165 lines.append(f"{prefix}_time_cadence {solver_time('time_cadence', window['time_cadence'])!r}")
13166 lines.append(f"{prefix}_field_count {len(window['fields'])}")
13167 for field_index, field_entry in enumerate(window["fields"]):
13168 lines.append(f"{prefix}_field_{field_index}_name {field_entry['field']}")
13169 lines.append(f"{prefix}_field_{field_index}_moments {','.join(field_entry['moments'])}")
13170 lines.append(f"{prefix}_covariance_count {len(window['covariances'])}")
13171 for pair_index, pair in enumerate(window["covariances"]):
13172 lines.append(f"{prefix}_covariance_{pair_index} {pair[0]},{pair[1]}")
13173 return lines
13174
13175
13176def resolve_statistics_console_output_frequency(io_cfg: dict) -> "int | None":
13177 """!
13178 @brief Resolve the statistics console cadence, mirroring the particle one.
13179 @param[in] io_cfg Parsed monitor `io` block.
13180 @return Value returned by `resolve_statistics_console_output_frequency()`.
13181 """
13182 if 'statistics_console_output_frequency' in io_cfg:
13183 return io_cfg['statistics_console_output_frequency']
13184 return io_cfg.get('data_output_frequency')
13185
13186
13187def normalize_solution_monitoring_config(monitor_cfg: dict) -> dict:
13188 """!
13189 @brief Validate and canonicalize physical-solution convergence monitoring.
13190 @param[in] monitor_cfg Parsed monitor.yml mapping.
13191 @return Canonical solution-monitoring configuration.
13192 @throws ValueError when convergence settings are invalid.
13193 """
13194 if not isinstance(monitor_cfg, dict):
13195 raise ValueError("monitor.yml must be a mapping before solution monitoring can be normalized.")
13196 monitoring = monitor_cfg.get("solution_monitoring", {}) or {}
13197 if not isinstance(monitoring, dict):
13198 raise ValueError("solution_monitoring must be a mapping when provided.")
13199 convergence = monitoring.get("convergence", {}) or {}
13200 if not isinstance(convergence, dict):
13201 raise ValueError("solution_monitoring.convergence must be a mapping when provided.")
13202 enabled = convergence.get("enabled", True)
13203 if not isinstance(enabled, bool):
13204 raise ValueError("solution_monitoring.convergence.enabled must be boolean.")
13205 mode = normalize_solution_convergence_mode(convergence.get("mode", "steady_deterministic"))
13206 normalized = {"enabled": enabled, "mode": mode.lower()}
13207 periodic_cfg = convergence.get("periodic_deterministic")
13208 statistical_cfg = convergence.get("statistical_steady")
13209 if mode == "PERIODIC_DETERMINISTIC":
13210 if not isinstance(periodic_cfg, dict):
13211 raise ValueError(
13212 "solution_monitoring.convergence.periodic_deterministic is required for periodic_deterministic mode."
13213 )
13214 period_steps = periodic_cfg.get("period_steps")
13215 if isinstance(period_steps, bool) or not isinstance(period_steps, int) or period_steps <= 0:
13216 raise ValueError(
13217 "solution_monitoring.convergence.periodic_deterministic.period_steps must be a positive integer."
13218 )
13219 normalized["periodic_deterministic"] = {"period_steps": period_steps}
13220 elif periodic_cfg is not None:
13221 raise ValueError(
13222 "solution_monitoring.convergence.periodic_deterministic is only valid for periodic_deterministic mode."
13223 )
13224 if mode == "STATISTICAL_STEADY":
13225 if not isinstance(statistical_cfg, dict):
13226 raise ValueError(
13227 "solution_monitoring.convergence.statistical_steady is required for statistical_steady mode."
13228 )
13229 window_steps = statistical_cfg.get("window_steps")
13230 if isinstance(window_steps, bool) or not isinstance(window_steps, int) or window_steps <= 0:
13231 raise ValueError(
13232 "solution_monitoring.convergence.statistical_steady.window_steps must be a positive integer."
13233 )
13234 normalized["statistical_steady"] = {"window_steps": window_steps}
13235 elif statistical_cfg is not None:
13236 raise ValueError(
13237 "solution_monitoring.convergence.statistical_steady is only valid for statistical_steady mode."
13238 )
13239 return {"convergence": normalized}
13240
13241
13242def resolve_solution_monitoring_flags(monitor_cfg: dict) -> dict:
13243 """!
13244 @brief Translate solution-monitoring YAML into the existing C convergence flags.
13245 @param[in] monitor_cfg Parsed monitor.yml mapping.
13246 @return Mapping of convergence options to explicit runtime values.
13247 """
13248 convergence = normalize_solution_monitoring_config(monitor_cfg)["convergence"]
13249 flags = {
13250 "-solution_convergence_enabled": "true" if convergence["enabled"] else "false",
13251 "-solution_convergence_mode": f'"{normalize_solution_convergence_mode(convergence["mode"])}"',
13252 }
13253 if "periodic_deterministic" in convergence:
13254 flags["-solution_convergence_period_steps"] = convergence["periodic_deterministic"]["period_steps"]
13255 if "statistical_steady" in convergence:
13256 flags["-solution_convergence_window_steps"] = convergence["statistical_steady"]["window_steps"]
13257 return flags
13258
13259
13260def prepare_monitor_files(run_dir: str, run_id: str, monitor_cfg: dict, source_files: dict,
13261 config_dir: str = None) -> dict:
13262 """!
13263 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
13264 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
13265 @param[in] run_id Argument passed to `prepare_monitor_files()`.
13266 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
13267 @param[in] source_files Argument passed to `prepare_monitor_files()`.
13268 @param[in] config_dir Optional configuration revision directory.
13269 @return Value returned by `prepare_monitor_files()`.
13270 """
13271 print("[INFO] Generating monitoring files...")
13272
13273 whitelist_path = None
13274 if has_explicit_monitor_whitelist(monitor_cfg):
13276 monitor_cfg["logging"]["enabled_functions"], "monitor.logging.enabled_functions")
13277 whitelist_path = generate_simple_list_file(
13278 run_dir, run_id, {"logging": {"enabled_functions": enabled}}, "logging", "enabled_functions",
13279 "whitelist.run", source_files, config_dir=config_dir,
13280 )
13281 else:
13282 print("[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
13283
13284 profiling_cfg = resolve_profiling_config(monitor_cfg)
13285
13286 profile_path = None
13287 if profiling_cfg["mode"] == "selected":
13288 profile_path = generate_simple_list_file(
13289 run_dir,
13290 run_id,
13291 {"profiling": {"selected_functions": profiling_cfg["functions"]}},
13292 "profiling",
13293 "selected_functions",
13294 "profile.run",
13295 source_files,
13296 config_dir=config_dir,
13297 )
13298 else:
13299 print(f"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
13300
13301 return {
13302 "whitelist": whitelist_path,
13303 "profile": profile_path,
13304 "profiling": profiling_cfg,
13305 }
13306
13307def generate_multi_block_bcs(run_dir: str, run_id: str, case_cfg: dict, source_files: dict,
13308 config_dir: str = None) -> list:
13309 """!
13310 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
13311 and returns a list of their absolute paths.
13312 @details Handles both simple list format (for single-block cases) and a
13313 list-of-lists (for multi-block cases) for boundary conditions.
13314 @param[in] run_dir The path to the main run directory.
13315 @param[in] run_id The unique identifier for the run.
13316 @param[in] case_cfg The parsed case.yml configuration dictionary.
13317 @param[in] source_files A dictionary of source files for the header.
13318 @param[in] config_dir Optional configuration revision directory.
13319 @return A list of absolute paths to the generated BC files.
13320 @throws ValueError if the number of BC definitions does not match the number of blocks.
13321 """
13322 print("[INFO] Generating boundary condition files...")
13323 config_dir = config_dir or os.path.join(run_dir, "config")
13324 os.makedirs(config_dir, exist_ok=True)
13325 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
13326 os.makedirs(profile_dir, exist_ok=True)
13327 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
13328 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
13329 case_path = source_files.get("Case") if source_files else None
13330 profile_grid_dims = None
13331 # Every profile source stages a physical velocity PICSLICE.
13332 profile_velocity_scale = reference_scale(BC_PARAM_QUANTITIES["source"][0], resolve_reference_scales(case_cfg))
13333
13334 if any(bc.get("handler") == "prescribed_flow" for block in prepared_blocks for bc in block):
13335 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
13336
13337 generated_files = []
13338 generated_profile_summaries = []
13339 generated_target_grid = None
13340 field_slice_target_grid = None
13341 for i, block_bcs_list in enumerate(prepared_blocks):
13342 file_name = "bcs.run" if num_blocks == 1 else f"bcs_block{i}.run"
13343 bcs_file_path = os.path.join(config_dir, file_name)
13344 bcs_lines = [generate_header(run_id, source_files)]
13345
13346 for bc in block_bcs_list:
13347 face, bc_type, handler = bc['face'], bc['type'], bc['handler']
13348 params = dict(bc.get('params') or {})
13349 if handler == "prescribed_flow":
13350 source = params.pop("source")
13351 expected_dims = _bc_profile_expected_dims(face, profile_grid_dims[i])
13352 staged_name = f"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
13353 staged_path = os.path.join(profile_dir, staged_name)
13354 if os.path.isfile(staged_path):
13355 with open(staged_path, "r", encoding="utf-8") as stream:
13356 header = [line.strip() for line in stream if line.strip() and not line.lstrip().startswith("#")]
13357 if len(header) < 3 or header[0] != "PICSLICE":
13358 raise ValueError(f"Locked inlet profile is not a PICSLICE file: {staged_path}")
13359 try:
13360 actual_dims = tuple(int(token) for token in header[2].split())
13361 except ValueError as exc:
13362 raise ValueError(f"Locked inlet profile has invalid dimensions: {staged_path}") from exc
13363 if actual_dims != tuple(expected_dims):
13364 raise ValueError(
13365 f"Locked inlet profile {staged_path} has dimensions {actual_dims}; "
13366 f"expected {tuple(expected_dims)}."
13367 )
13368 print(
13369 f"[INFO] Reusing locked prescribed_flow profile for block {i}, "
13370 f"face {face}: {os.path.relpath(staged_path)}"
13371 )
13372 params["source_file"] = os.path.abspath(staged_path)
13373 source = None
13374 elif source["type"] == "file":
13375 source_path = _resolve_case_relative_path(
13376 source["path"], os.path.dirname(os.path.abspath(case_path))
13377 )
13378 elif source["type"] == "generated":
13379 source_path = os.path.join(
13380 profile_dir,
13381 f"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.dimensional.picslice",
13382 )
13383 if os.path.abspath(source_path) == os.path.abspath(staged_path):
13384 raise ValueError(
13385 f"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
13386 )
13387 if source["generator"] == "square_duct_poiseuille":
13388 if generated_target_grid is None:
13389 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
13391 source_path,
13392 expected_dims,
13393 source["params"],
13394 target_grid=generated_target_grid,
13395 target_block=i,
13396 target_face=face,
13397 script=source.get("script"),
13398 case_path=case_path,
13399 )
13400 else:
13401 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
13402 summary.update({"block": i, "face": face})
13403 generated_profile_summaries.append(summary)
13404 elif source["type"] == "field_slice":
13405 source_path = os.path.join(
13406 profile_dir,
13407 f"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.dimensional.picslice",
13408 )
13409 if os.path.abspath(source_path) == os.path.abspath(staged_path):
13410 raise ValueError(
13411 f"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
13412 )
13413 if field_slice_target_grid is None:
13414 field_slice_target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
13416 source_path,
13417 expected_dims,
13418 source,
13419 field_slice_target_grid,
13420 face,
13421 i,
13422 case_path,
13423 )
13424 summary.update({"block": i, "face": face})
13425 generated_profile_summaries.append(summary)
13426 elif source is not None:
13427 raise ValueError(f"Unsupported prescribed_flow source type '{source.get('type')}'.")
13428 if source is not None:
13429 summary = validate_and_nondimensionalize_picslice(source_path, staged_path, profile_velocity_scale, expected_dims)
13430 print(
13431 f"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
13432 f"{os.path.relpath(staged_path)} dims={summary['dims']}"
13433 )
13434 params["source_file"] = os.path.abspath(staged_path)
13435 params_str = ""
13436 if params:
13437 parts = []
13438 for k, v in params.items():
13439 if isinstance(v, bool):
13440 value_str = "true" if v else "false"
13441 else:
13442 value_str = str(v)
13443 parts.append(f"{k}={value_str}")
13444 params_str = " ".join(parts)
13445 bcs_lines.append(f"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
13446
13447 with open(bcs_file_path, "w") as f: f.write("\n".join(bcs_lines))
13448
13449 print(f"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
13450 generated_files.append(os.path.abspath(bcs_file_path))
13451
13452 if generated_profile_summaries:
13453 info_path = write_profile_info(profile_dir, generated_profile_summaries)
13454 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
13455
13456 return generated_files
13457
13459 """!
13460 @brief Converts Python types to C-style command-line flag values.
13461 @param[in] value The Python object to convert (bool, list, or other).
13462 @return A string representation suitable for a C command-line parser.
13463 """
13464 if isinstance(value, bool):
13465 return "1" if value else "0"
13466 if isinstance(value, list):
13467 return ",".join(map(str, value))
13468 return str(value)
13469
13470def translate_programmatic_grid_settings(grid_settings: dict) -> dict:
13471 """!
13472 @brief Return programmatic-grid settings translated to the C node-count contract.
13473 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
13474 @return Value returned by `translate_programmatic_grid_settings()`.
13475 """
13476 translated = dict(grid_settings)
13477 for dim_key in ("im", "jm", "km"):
13478 if dim_key in translated:
13479 raw_val = translated[dim_key]
13480 if not isinstance(raw_val, int) or raw_val <= 0:
13481 raise ValueError(
13482 f"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
13483 f"(got {raw_val!r})."
13484 )
13485 translated[dim_key] = raw_val + 1
13486 return translated
13487
13488
13489PROGRAMMATIC_BRIDGE_GRID_KEYS = (
13490 "im", "jm", "km",
13491 "xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs",
13492 "rxs", "rys", "rzs",
13493)
13494
13495
13496def validate_programmatic_bridge_grid_settings(raw_settings: dict) -> None:
13497 """!
13498 @brief Validate the scalar programmatic grid settings the bridge PICGRID is built from.
13499 @details A Python generator - an initial condition or a generated inlet profile - runs
13500 as a separate process and reads the programmatic grid from that bridge, which
13501 supports one block with scalar settings.
13502 @param[in] raw_settings programmatic_settings dict from case.yml.
13503 @throws ValueError when required scalar settings are missing or invalid.
13504 """
13505 if not isinstance(raw_settings, dict):
13506 raise ValueError(
13507 "grid.programmatic_settings must be a mapping when a Python generator reads the grid."
13508 )
13509
13510 missing = [key for key in PROGRAMMATIC_BRIDGE_GRID_KEYS if key not in raw_settings]
13511 if missing:
13512 raise ValueError(
13513 "grid.programmatic_settings must include "
13514 f"{missing} when grid.mode is 'programmatic_c' and a Python generator reads the grid."
13515 )
13516
13517 for key in ("im", "jm", "km"):
13518 value = raw_settings[key]
13519 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
13520 raise ValueError(
13521 f"grid.programmatic_settings.{key} must be a positive scalar integer cell count "
13522 "when a Python generator reads the programmatic_c grid."
13523 )
13524
13525 for key in ("xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs", "rxs", "rys", "rzs"):
13526 value = raw_settings[key]
13527 if isinstance(value, (list, tuple, dict, bool)):
13528 raise ValueError(
13529 f"grid.programmatic_settings.{key} must be a scalar numeric value "
13530 "when a Python generator reads the programmatic_c grid."
13531 )
13532 try:
13533 numeric = float(value)
13534 except (TypeError, ValueError):
13535 raise ValueError(
13536 f"grid.programmatic_settings.{key} must be a scalar numeric value "
13537 "when a Python generator reads the programmatic_c grid."
13538 )
13539 if not math.isfinite(numeric):
13540 raise ValueError(
13541 f"grid.programmatic_settings.{key} must be finite "
13542 "when a Python generator reads the programmatic_c grid."
13543 )
13544 if key in {"rxs", "rys", "rzs"} and numeric <= 0.0:
13545 raise ValueError(
13546 f"grid.programmatic_settings.{key} must be positive "
13547 "when a Python generator reads the programmatic_c grid."
13548 )
13549
13550
13551def generate_picgrid_from_programmatic_settings(raw_settings: dict, dest_path: str, L_ref: float) -> dict:
13552 """!
13553 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
13554 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
13555 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
13556 @param[in] raw_settings programmatic_settings dict from case.yml.
13557 @param[in] dest_path Destination PICGRID file path.
13558 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
13559 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
13560 """
13562 if L_ref == 0.0:
13563 raise ValueError("length_ref must be non-zero for programmatic grid generation.")
13564 IM = int(raw_settings.get("im", 0)) + 1
13565 JM = int(raw_settings.get("jm", 0)) + 1
13566 KM = int(raw_settings.get("km", 0)) + 1
13567 if IM < 2 or JM < 2 or KM < 2:
13568 raise ValueError(
13569 f"programmatic_settings im/jm/km must each be >= 1 "
13570 f"(got im={IM-1}, jm={JM-1}, km={KM-1})."
13571 )
13572 x_min = float(raw_settings.get("xMins", 0.0))
13573 x_max = float(raw_settings.get("xMaxs", 1.0))
13574 y_min = float(raw_settings.get("yMins", 0.0))
13575 y_max = float(raw_settings.get("yMaxs", 1.0))
13576 z_min = float(raw_settings.get("zMins", 0.0))
13577 z_max = float(raw_settings.get("zMaxs", 1.0))
13578 rx = float(raw_settings.get("rxs", 1.0))
13579 ry = float(raw_settings.get("rys", 1.0))
13580 rz = float(raw_settings.get("rzs", 1.0))
13581
13582 def _stretched(idx, N, length, r):
13583 """!
13584 @brief Mirror of ComputeStretchedCoord from src/grid.c.
13585 @param[in] idx Node index along the axis.
13586 @param[in] N Total node count along the axis.
13587 @param[in] length Physical length of the axis.
13588 @param[in] r Geometric stretching ratio.
13589 @return Coordinate offset from the axis minimum.
13590 """
13591 frac = idx / (N - 1.0)
13592 if abs(r - 1.0) < 1.0e-9:
13593 return length * frac
13594 return length * (r ** frac - 1.0) / (r - 1.0)
13595
13596 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
13597 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
13598 with open(dest_path, "w") as fout:
13599 fout.write("PICGRID\n1\n")
13600 fout.write(f"{IM} {JM} {KM}\n")
13601 for k in range(KM):
13602 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
13603 for j in range(JM):
13604 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
13605 for i in range(IM):
13606 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
13607 fout.write(
13608 f"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
13609 f"{format_picgrid_coordinate(z)}\n"
13610 )
13611 total_nodes = IM * JM * KM
13612 return {"nblk": 1, "dims": [(IM, JM, KM)], "total_nodes": total_nodes}
13613
13614
13615GRID_DA_PROCESSOR_KEYS = ("da_processors_x", "da_processors_y", "da_processors_z")
13616
13617
13618def resolve_grid_da_processor_layout(grid_cfg: dict) -> dict:
13619 """!
13620 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
13621 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
13622 @return Value returned by `resolve_grid_da_processor_layout()`.
13623 """
13624 top_level = {}
13625 legacy = {}
13626
13627 for key in GRID_DA_PROCESSOR_KEYS:
13628 value = grid_cfg.get(key)
13629 if isinstance(value, (list, tuple)):
13630 raise ValueError(
13631 f"grid.{key} must be a scalar integer. "
13632 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
13633 )
13634 if value is not None:
13635 if not isinstance(value, int) or value <= 0:
13636 raise ValueError(f"grid.{key} must be a positive integer when provided (got {value}).")
13637 top_level[key] = value
13638
13639 legacy_settings = grid_cfg.get("programmatic_settings")
13640 if isinstance(legacy_settings, dict):
13641 for key in GRID_DA_PROCESSOR_KEYS:
13642 value = legacy_settings.get(key)
13643 if isinstance(value, (list, tuple)):
13644 raise ValueError(
13645 f"grid.programmatic_settings.{key} must be a scalar integer. "
13646 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
13647 )
13648 if value is not None:
13649 if not isinstance(value, int) or value <= 0:
13650 raise ValueError(
13651 f"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
13652 )
13653 legacy[key] = value
13654
13655 resolved = {}
13656 for key in GRID_DA_PROCESSOR_KEYS:
13657 top_value = top_level.get(key)
13658 legacy_value = legacy.get(key)
13659 if top_value is not None and legacy_value is not None and top_value != legacy_value:
13660 raise ValueError(
13661 f"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
13662 "define the processor layout in only one place."
13663 )
13664 if top_value is not None:
13665 resolved[key] = top_value
13666 elif legacy_value is not None:
13667 resolved[key] = legacy_value
13668
13669 return resolved
13670
13671
13672def resolve_grid_node_dims(case_cfg: dict, case_path: str,
13673 staged_grid: "str | None" = None) -> "list | None":
13674 """!
13675 @brief Per-block (IM, JM, KM) node counts the solver will read, without building the grid.
13676 @details A staged PICGRID, when one exists, is authoritative. Otherwise a file grid's
13677 header is read, a programmatic grid's cell counts are converted to nodes,
13678 and a `grid_gen` grid's cell counts are resolved by grid.gen's own
13679 parameter precedence without generating any coordinates.
13680 @param[in] case_cfg Parsed case configuration.
13681 @param[in] case_path Path to case.yml, for case-relative paths.
13682 @param[in] staged_grid Already staged `inputs/grid/grid.run`, if any.
13683 @return List of node-count tuples, or None when the dimensions cannot be determined.
13684 """
13685 grid_cfg = case_cfg.get("grid") or {}
13686 expected_nblk = int(((case_cfg.get("models") or {}).get("domain") or {}).get("blocks", 1))
13687 case_dir = os.path.dirname(os.path.abspath(case_path))
13688 mode = grid_cfg.get("mode")
13689 try:
13690 if staged_grid and os.path.isfile(staged_grid):
13691 return read_picgrid_header_dimensions(staged_grid, expected_nblk)
13692 if mode == "file":
13694 _resolve_case_relative_path(grid_cfg["source_file"], case_dir), expected_nblk)
13695 if mode == "programmatic_c":
13696 settings = translate_programmatic_grid_settings(grid_cfg.get("programmatic_settings") or {})
13697 return [(settings["im"], settings["jm"], settings["km"])]
13698 if mode == "grid_gen":
13699 generator = grid_cfg.get("generator") or {}
13700 if generator.get("script"):
13701 return None
13702 tokens = ["-c", _resolve_case_relative_path(generator["config_file"], case_dir)]
13703 if generator.get("grid_type"):
13704 tokens.append(str(generator["grid_type"]))
13705 tokens.extend(str(token) for token in (generator.get("cli_args") or []))
13706 grid_gen = load_generator_module("grid.gen", "picurv_grid_generator")
13707 with contextlib.redirect_stdout(StringIO()), contextlib.redirect_stderr(StringIO()):
13708 _, params = grid_gen.resolve_grid_parameters(tokens)
13709 return [(params.ncells_i + 1, params.ncells_j + 1, params.ncells_k + 1)]
13710 except (SystemExit, Exception):
13711 # Staging reports a malformed grid in its own terms; this check only adds to it.
13712 return None
13713 return None
13714
13715
13716def case_declares_periodic_axis(case_cfg: dict) -> bool:
13717 """!
13718 @brief Whether any face of any block is PERIODIC, which sets the DMDA stencil width.
13719 @param[in] case_cfg Parsed case configuration.
13720 @return True when grid setup will use stencil width 3 on every axis.
13721 """
13722 try:
13724 except ValueError:
13725 return False
13726 return any(entry.get("type") == "PERIODIC" for block in blocks for entry in block)
13727
13728
13729def da_processor_layout_problems(grid_cfg: dict, num_procs: "int | None",
13730 block_dims: "list | None" = None, solver_cfg: "dict | None" = None,
13731 any_periodic: bool = False, source: "str | None" = None) -> list:
13732 """!
13733 @brief Problems with a user-set DMDA layout that would abort grid setup.
13734 @details Mirrors `InitializeAllGridDMs()` in src/grid.c: each coarser level holds
13735 (IM + 1) / 2 nodes on an axis that is not semi-coarsened, the DMDA carries
13736 IM + 1 points, and PETSc's default split gives the smallest rank
13737 points // ranks of them. Grid setup requires that to reach the stencil
13738 width, which is 3 on every axis when any face is periodic and 2 otherwise.
13739 Only the coarsest level can fail, because every finer level is aligned to
13740 twice its widths.
13741 @param[in] grid_cfg The case's grid block.
13742 @param[in] num_procs MPI processes the solve will launch, or None when unknown (validate
13743 without a cluster profile), which skips only the product check.
13744 @param[in] block_dims Per-block node counts, or None to skip the multigrid check.
13745 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels and semi-coarsening.
13746 @param[in] any_periodic Whether any face is PERIODIC.
13747 @param[in] source Where the layout was given, as `--ni/--nj/--nk` or
13748 `cluster.yml execution.decomposition`; None for case.yml's own keys.
13749 @return List of messages; empty when the layout is usable or not set.
13750 """
13751 layout = resolve_grid_da_processor_layout(grid_cfg)
13752 if source == "--ni/--nj/--nk":
13753 names = dict(zip(GRID_DA_PROCESSOR_KEYS, ("--ni", "--nj", "--nk")))
13754 together = "--ni x --nj x --nk"
13755 elif source:
13756 names = {key: f"{source}.{axis}" for key, axis in zip(GRID_DA_PROCESSOR_KEYS, "ijk")}
13757 together = f"{source} (i, j, k)"
13758 else:
13759 names = {key: f"grid.{key}" for key in GRID_DA_PROCESSOR_KEYS}
13760 together = "grid.da_processors_x/y/z"
13761 if not layout or (num_procs is not None and num_procs <= 1):
13762 return []
13763 problems = []
13764 if num_procs is not None and all(layout.get(key) is not None for key in GRID_DA_PROCESSOR_KEYS):
13765 total_layout = 1
13766 for key in GRID_DA_PROCESSOR_KEYS:
13767 total_layout *= layout[key]
13768 if total_layout != num_procs:
13769 printable = " x ".join(str(layout[key]) for key in GRID_DA_PROCESSOR_KEYS)
13770 problems.append(
13771 "DMDA processor layout mismatch: "
13772 f"{together} is {printable} (product {total_layout}), "
13773 f"but this run requests {num_procs} MPI processes. "
13774 "Give values whose product equals the requested MPI process count, or "
13775 "remove all three settings to let PICurv choose the layout."
13776 )
13777 if not block_dims:
13778 return problems
13779 multigrid = (((solver_cfg or {}).get("poisson_solver") or {}).get("multigrid") or {})
13780 levels = int(multigrid.get("levels", 3))
13781 semi = multigrid.get("semi_coarsening") or {}
13782 stencil = 3 if any_periodic else 2
13783 for block, dims in enumerate(block_dims):
13784 for axis, key, nodes in zip("ijk", GRID_DA_PROCESSOR_KEYS, dims):
13785 ranks = layout.get(key)
13786 if ranks is None:
13787 continue
13788 coarse = nodes
13789 if str(semi.get(axis, False)).strip().lower() not in ("true", "1", "yes"):
13790 for _ in range(levels - 1):
13791 coarse = (coarse + 1) // 2
13792 points = coarse + 1
13793 if points // ranks < stencil:
13794 problems.append(
13795 f"{names[key]} = {ranks} is too many ranks for block {block}'s "
13796 f"{axis} axis at {levels} multigrid levels: the coarsest level has "
13797 f"{points} points there, so a rank gets {points // ranks}, below the "
13798 f"stencil width {stencil}"
13799 + (" (3 on every axis because a face is PERIODIC)" if any_periodic else "")
13800 + f". Grid setup would abort with 'Local width ... is smaller than "
13801 f"stencil width'. Use at most {max(points // stencil, 1)} rank(s) on "
13802 "this axis, or lower poisson_solver.multigrid.levels."
13803 )
13804 return problems
13805
13806
13808 """!
13809 @brief Resolve `-n` and the optional `--ni/--nj/--nk` of `picurv run` into one rank count.
13810 @details The three counts go together. Given them, `-n` defaults to their product and
13811 must equal it when also given; without them, `-n` defaults to 1. Sets
13812 `args.num_procs` and `args.decomposition` (a tuple or None).
13813 @param[in,out] args Parsed `picurv run` arguments.
13814 """
13815 parts = [getattr(args, key, None) for key in ("ni", "nj", "nk")]
13816 args.decomposition = None
13817 if any(part is not None for part in parts):
13818 if any(part is None or part <= 0 for part in parts):
13819 fail_cli_usage("--ni, --nj and --nk are given together, as positive integers.")
13820 product = parts[0] * parts[1] * parts[2]
13821 if args.num_procs is None:
13822 args.num_procs = product
13823 elif args.num_procs != product:
13824 fail_cli_usage(f"--ni x --nj x --nk is {parts[0]} x {parts[1]} x {parts[2]} = {product} ranks, "
13825 f"but -n is {args.num_procs}; give one or make them agree.")
13826 args.decomposition = tuple(parts)
13827 if args.num_procs is None:
13828 args.num_procs = 1
13829 if args.num_procs < 1:
13830 fail_cli_usage("-n must be a positive integer.")
13831
13832
13833def _decomposition_layout(layout) -> dict:
13834 """!
13835 @brief Express an (ni, nj, nk) tuple as the da_processors mapping the layout checks read.
13836 @param[in] layout Rank counts along i, j, k.
13837 @return {"da_processors_x": ni, "da_processors_y": nj, "da_processors_z": nk}.
13838 """
13839 return dict(zip(GRID_DA_PROCESSOR_KEYS, layout))
13840
13841
13842def choose_decomposition(num_procs: int, block_dims: list, solver_cfg: "dict | None",
13843 any_periodic: bool):
13844 """!
13845 @brief Choose the MPI decomposition of a structured grid for a rank count.
13846 @details Every ordered (ni, nj, nk) with product `num_procs` that grid setup accepts at
13847 the multigrid levels in use (da_processor_layout_problems) is a candidate. The
13848 one cutting the least grid area between ranks wins, so a thin axis, such as the
13849 i axis of a 2D case, is not split while another can be; ties go to the most even
13850 split, then to more ranks on k, then on j.
13851 @param[in] num_procs MPI ranks.
13852 @param[in] block_dims Per-block node counts.
13853 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels and semi-coarsening.
13854 @param[in] any_periodic Whether any face is PERIODIC, which sets the stencil width.
13855 @return (ni, nj, nk), or None when no decomposition is feasible.
13856 """
13857 best = None
13858 for ni in range(1, num_procs + 1):
13859 if num_procs % ni:
13860 continue
13861 for nj in range(1, num_procs // ni + 1):
13862 if (num_procs // ni) % nj:
13863 continue
13864 layout = (ni, nj, num_procs // ni // nj)
13865 if da_processor_layout_problems(_decomposition_layout(layout), num_procs, block_dims,
13866 solver_cfg, any_periodic):
13867 continue
13868 cut = imbalance = 0.0
13869 for dims in block_dims:
13870 cells = [max(d - 1, 1) for d in dims]
13871 cut += sum((layout[a] - 1) * cells[(a + 1) % 3] * cells[(a + 2) % 3] for a in range(3))
13872 largest = 1.0
13873 for a in range(3):
13874 largest *= -(-cells[a] // layout[a])
13875 imbalance = max(imbalance, largest * num_procs / (cells[0] * cells[1] * cells[2]))
13876 key = (cut, imbalance, -layout[2], -layout[1])
13877 if best is None or key < best[0]:
13878 best = (key, layout)
13879 return best[1] if best else None
13880
13881
13882def resolve_decomposition(num_procs: int, grid_cfg: dict, block_dims: "list | None",
13883 solver_cfg: "dict | None", any_periodic: bool,
13884 explicit=None, cluster_cfg: "dict | None" = None) -> dict:
13885 """!
13886 @brief Resolve the MPI decomposition a stage launches with.
13887 @details In order of precedence: `--ni/--nj/--nk`, cluster.yml
13888 `execution.decomposition`, the deprecated case.yml `grid.da_processors_*`, and
13889 otherwise choose_decomposition(). A set layout must multiply to the rank count
13890 and pass the multigrid check. When the grid's size cannot be known before the
13891 run, PETSc chooses.
13892 @param[in] num_procs MPI ranks of the stage.
13893 @param[in] grid_cfg The case's grid block.
13894 @param[in] block_dims Per-block node counts, or None when unknown.
13895 @param[in] solver_cfg Parsed solver.yml.
13896 @param[in] any_periodic Whether any face is PERIODIC.
13897 @param[in] explicit (ni, nj, nk) from the command line, or None.
13898 @param[in] cluster_cfg Parsed cluster.yml, or None.
13899 @return {"layout": (ni, nj, nk) or None, "source": str, "problems": [str]}.
13900 """
13901 if num_procs <= 1:
13902 return {"layout": (1, 1, 1), "source": "serial", "problems": []}
13903 layout, source = None, None
13904 if explicit:
13905 layout, source = tuple(explicit), "--ni/--nj/--nk"
13906 elif isinstance(((cluster_cfg or {}).get("execution") or {}).get("decomposition"), dict):
13907 spec = cluster_cfg["execution"]["decomposition"]
13908 if not all(isinstance(spec.get(axis), int) and spec.get(axis) > 0 for axis in "ijk"):
13909 return {"layout": None, "source": "cluster.yml",
13910 "problems": ["cluster.yml execution.decomposition needs positive integers i, j and k."]}
13911 layout, source = (spec["i"], spec["j"], spec["k"]), "cluster.yml execution.decomposition"
13912 else:
13913 legacy = resolve_grid_da_processor_layout(grid_cfg or {})
13914 if legacy:
13915 print("[WARNING] case.yml grid.da_processors_x/y/z is deprecated; give the decomposition "
13916 "with --ni/--nj/--nk or cluster.yml execution.decomposition.", file=sys.stderr)
13917 if len(legacy) == 3:
13918 layout, source = tuple(legacy[key] for key in GRID_DA_PROCESSOR_KEYS), "case.yml (deprecated)"
13919 else:
13920 problems = da_processor_layout_problems(grid_cfg, num_procs, block_dims, solver_cfg, any_periodic)
13921 return {"layout": None, "partial": legacy, "source": "case.yml (deprecated, partial)",
13922 "problems": problems}
13923 if layout is not None:
13924 return {"layout": layout, "source": source,
13925 "problems": da_processor_layout_problems(
13926 _decomposition_layout(layout), num_procs, block_dims, solver_cfg, any_periodic,
13927 None if source.startswith("case.yml") else source)}
13928 if not block_dims:
13929 return {"layout": None, "source": "PETSc (grid size unknown before the run)", "problems": []}
13930 layout = choose_decomposition(num_procs, block_dims, solver_cfg, any_periodic)
13931 if layout is None:
13932 feasible = [n for n in range(max(num_procs - 16, 2), num_procs + 17)
13933 if choose_decomposition(n, block_dims, solver_cfg, any_periodic)]
13934 nearest = sorted(feasible, key=lambda n: (abs(n - num_procs), n))[:3]
13935 return {"layout": None, "source": "auto", "problems": [
13936 f"No decomposition of {num_procs} ranks fits this grid at its multigrid levels "
13937 f"(each rank needs at least the stencil width of points on the coarsest level). "
13938 + (f"Rank counts that fit nearby: {', '.join(map(str, sorted(nearest)))}." if nearest
13939 else "Lower poisson_solver.multigrid.levels or refine the grid.")]}
13940 return {"layout": layout, "source": "auto", "problems": []}
13941
13942
13943def _decomposition_record(resolved: "dict | None") -> "dict | None":
13944 """!
13945 @brief The JSON-serializable record of a resolved decomposition for run metadata.
13946 @param[in] resolved Result of resolve_decomposition(), or None.
13947 @return {"layout": [ni, nj, nk] or None, "source": str}, or None.
13948 """
13949 if not resolved:
13950 return None
13951 return {"layout": list(resolved["layout"]) if resolved.get("layout") else None,
13952 "source": resolved["source"]}
13953
13954
13955def describe_decomposition(resolved: dict, num_procs: int) -> str:
13956 """!
13957 @brief One line stating the decomposition a stage launches with.
13958 @param[in] resolved Result of resolve_decomposition().
13959 @param[in] num_procs MPI ranks of the stage.
13960 @return Human-readable description.
13961 """
13962 if resolved.get("problems"):
13963 return f"MPI decomposition: none usable for {num_procs} rank(s) ({resolved['source']})."
13964 if resolved.get("layout") is None:
13965 return f"MPI decomposition: chosen by PETSc for {num_procs} rank(s) ({resolved['source']})."
13966 ni, nj, nk = resolved["layout"]
13967 return f"MPI decomposition: {ni} x {nj} x {nk} (i x j x k) for {num_procs} rank(s), from {resolved['source']}."
13968
13969
13970def append_grid_da_processor_layout(control_lines: list, grid_cfg: dict, num_procs: int,
13971 block_dims: "list | None" = None,
13972 solver_cfg: "dict | None" = None,
13973 any_periodic: bool = False,
13974 explicit=None, cluster_cfg: "dict | None" = None) -> dict:
13975 """!
13976 @brief Resolve the solver's MPI decomposition, report it, and append its flags.
13977 @param[in] control_lines Control-file lines to extend.
13978 @param[in] grid_cfg The case's grid block.
13979 @param[in] num_procs MPI ranks of the solve.
13980 @param[in] block_dims Per-block node counts, for the multigrid check and the choice.
13981 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels.
13982 @param[in] any_periodic Whether any face is PERIODIC.
13983 @param[in] explicit (ni, nj, nk) from the command line, or None.
13984 @param[in] cluster_cfg Parsed cluster.yml, or None.
13985 @return The resolved decomposition (resolve_decomposition()).
13986 @throws ValueError when the decomposition would abort grid setup or none fits.
13987 """
13988 resolved = resolve_decomposition(num_procs, grid_cfg, block_dims, solver_cfg, any_periodic,
13989 explicit, cluster_cfg)
13990 if resolved["problems"]:
13991 raise ValueError(" ".join(resolved["problems"]))
13992 print(f"[INFO] {describe_decomposition(resolved, num_procs)}")
13993 layout = resolved.get("layout")
13994 if num_procs > 1 and layout is not None:
13995 control_lines.extend(f"-{key} {value}" for key, value in zip(GRID_DA_PROCESSOR_KEYS, layout))
13996 elif num_procs > 1 and resolved.get("partial"):
13997 control_lines.extend(f"-{key} {value}" for key, value in resolved["partial"].items())
13998 return resolved
13999
14000def normalize_momentum_solver_type(value: str) -> str:
14001 """!
14002 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
14003 @param[in] value Canonical momentum solver string from YAML.
14004 @return Canonical value accepted by -mom_solver_type.
14005 @throws ValueError if the input cannot be mapped.
14006 """
14007 # Only implemented YAML values belong here. Extend only with matching C enum/parser/dispatch support.
14008 if value is None:
14009 raise ValueError("momentum solver type cannot be None")
14010
14011 raw = str(value).strip()
14012 mapped = {
14013 "Explicit RK4": "EXPLICIT_RK",
14014 "Dual Time Picard Jameson RK": "DUALTIME_PICARD_JAMESON_RK",
14015 "Dual Time Picard RK4": "DUALTIME_PICARD_JAMESON_RK",
14016 "Newton Krylov": "newton_krylov",
14017 }.get(raw)
14018 if mapped is None:
14019 raise ValueError(
14020 f"Unknown momentum solver '{value}'. Use one of: "
14021 "'Explicit RK4', 'Dual Time Picard Jameson RK', 'Newton Krylov'."
14022 )
14023 return mapped
14024
14025
14026def validate_newton_krylov_config(cfg: dict) -> dict:
14027 """!
14028 @brief Validate and normalize the structured Newton--Krylov solver block.
14029 @param[in] cfg Structured `momentum_solver.newton_krylov` mapping.
14030 @return Normalized copy containing only supported structured fields.
14031 """
14032 root = "momentum_solver.newton_krylov"
14033 if not isinstance(cfg, dict):
14034 raise ValueError(f"{root} must be a mapping.")
14035
14036 unknown = sorted(set(cfg) - {
14037 "jacobian", "preconditioner", "nonlinear_solver", "linear_solver",
14038 })
14039 if unknown:
14040 raise ValueError(f"{root} has unsupported key(s): {unknown}.")
14041
14042 normalized = {}
14043
14044 def _mapping(parent: dict, key: str, path: str) -> dict:
14045 """!
14046 @brief Read and validate one optional nested Newton mapping.
14047 @param[in] parent Parent mapping.
14048 @param[in] key Nested key to read.
14049 @param[in] path User-facing YAML path for errors.
14050 @return Nested mapping, or an empty mapping when omitted.
14051 """
14052 value = parent.get(key, {})
14053 if value is None or not isinstance(value, dict):
14054 raise ValueError(f"{path} must be a mapping when provided.")
14055 return value
14056
14057 def _method(value, path: str) -> str:
14058 """!
14059 @brief Normalize one nonempty PETSc solver/type token.
14060 @param[in] value YAML token value.
14061 @param[in] path User-facing YAML path for errors.
14062 @return Lowercase PETSc token.
14063 """
14064 if not isinstance(value, str) or not value.strip():
14065 raise ValueError(f"{path} must be a non-empty string.")
14066 return value.strip().lower()
14067
14068 def _tolerance(value, path: str):
14069 """!
14070 @brief Validate one finite nonnegative tolerance.
14071 @param[in] value YAML tolerance value.
14072 @param[in] path User-facing YAML path for errors.
14073 @return Original validated value.
14074 """
14075 if isinstance(value, bool):
14076 raise ValueError(f"{path} must be numeric and nonnegative.")
14077 try:
14078 numeric = float(value)
14079 except (TypeError, ValueError) as exc:
14080 raise ValueError(f"{path} must be numeric and nonnegative.") from exc
14081 if not math.isfinite(numeric) or numeric < 0.0:
14082 raise ValueError(f"{path} must be numeric and nonnegative.")
14083 return value
14084
14085 def _positive_integer(value, path: str):
14086 """!
14087 @brief Validate one positive integer count.
14088 @param[in] value YAML count value.
14089 @param[in] path User-facing YAML path for errors.
14090 @return Original validated integer.
14091 """
14092 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
14093 raise ValueError(f"{path} must be a positive integer.")
14094 return value
14095
14096 jacobian_path = f"{root}.jacobian"
14097 if "jacobian" not in cfg:
14098 normalized["jacobian"] = {
14099 "type": "finite_difference",
14100 "finite_difference": {"mode": "matrix_free"},
14101 }
14102 else:
14103 jacobian = _mapping(cfg, "jacobian", jacobian_path)
14104 unknown = sorted(set(jacobian) - {"type", "finite_difference"})
14105 if unknown:
14106 raise ValueError(f"{jacobian_path} has unsupported key(s): {unknown}.")
14107 if "type" not in jacobian:
14108 raise ValueError(f"{jacobian_path}.type is required when {jacobian_path} is provided.")
14109 jacobian_type = _method(jacobian["type"], f"{jacobian_path}.type")
14110 if jacobian_type == "frozen_momentum_approximation":
14111 raise ValueError(
14112 f"{jacobian_path}.type 'frozen_momentum_approximation' is not implemented."
14113 )
14114 if jacobian_type != "finite_difference":
14115 raise ValueError(
14116 f"{jacobian_path}.type currently supports only 'finite_difference' "
14117 f"(got '{jacobian_type}')."
14118 )
14119 finite_difference_path = f"{jacobian_path}.finite_difference"
14120 if "finite_difference" not in jacobian:
14121 raise ValueError(
14122 f"{finite_difference_path} is required when {jacobian_path}.type is "
14123 "'finite_difference'."
14124 )
14125 finite_difference = _mapping(
14126 jacobian, "finite_difference", finite_difference_path
14127 )
14128 unknown = sorted(set(finite_difference) - {"mode"})
14129 if unknown:
14130 raise ValueError(f"{finite_difference_path} has unsupported key(s): {unknown}.")
14131 if "mode" not in finite_difference:
14132 raise ValueError(f"{finite_difference_path}.mode is required.")
14133 finite_difference_mode = _method(
14134 finite_difference["mode"], f"{finite_difference_path}.mode"
14135 )
14136 if finite_difference_mode == "colored_sparse":
14137 raise ValueError(
14138 f"{finite_difference_path}.mode 'colored_sparse' is not implemented."
14139 )
14140 if finite_difference_mode != "matrix_free":
14141 raise ValueError(
14142 f"{finite_difference_path}.mode currently supports only 'matrix_free' "
14143 f"(got '{finite_difference_mode}')."
14144 )
14145 normalized["jacobian"] = {
14146 "type": jacobian_type,
14147 "finite_difference": {"mode": finite_difference_mode},
14148 }
14149
14150 preconditioner = _mapping(
14151 cfg, "preconditioner", f"{root}.preconditioner"
14152 ) if "preconditioner" in cfg else {}
14153 preconditioner_path = f"{root}.preconditioner"
14154 unknown = sorted(set(preconditioner) - {"model", "structure"})
14155 if unknown:
14156 raise ValueError(f"{preconditioner_path} has unsupported key(s): {unknown}.")
14157 if "preconditioner" in cfg and "model" not in preconditioner:
14158 raise ValueError(
14159 f"{preconditioner_path}.model is required when {preconditioner_path} is provided."
14160 )
14161 model = _method(preconditioner.get("model", "none"), f"{preconditioner_path}.model")
14162 if model not in NEWTON_KRYLOV_PRECONDITIONER_MODELS:
14163 raise ValueError(
14164 f"{preconditioner_path}.model supports only 'none' or 'frozen_momentum_jacobian'."
14165 )
14166 structure = _mapping(
14167 preconditioner, "structure", f"{preconditioner_path}.structure"
14168 ) if "structure" in preconditioner else {}
14169 unknown = sorted(set(structure) - {"type"})
14170 if unknown:
14171 raise ValueError(f"{preconditioner_path}.structure has unsupported key(s): {unknown}.")
14172 structure_type = _method(
14173 structure.get("type", "none"), f"{preconditioner_path}.structure.type"
14174 )
14175 if structure_type not in NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES:
14176 raise ValueError(
14177 f"{preconditioner_path}.structure.type supports only 'none' or 'point_block'."
14178 )
14179 if model == "none" and structure:
14180 raise ValueError(f"{preconditioner_path}.model 'none' does not accept a matrix structure.")
14181 if model == "frozen_momentum_jacobian" and structure_type != "point_block":
14182 raise ValueError(
14183 f"{preconditioner_path}.model 'frozen_momentum_jacobian' requires "
14184 f"{preconditioner_path}.structure.type 'point_block'."
14185 )
14186 normalized["preconditioner"] = {
14187 "model": model,
14188 "structure": {"type": structure_type},
14189 }
14190
14191 nonlinear = _mapping(cfg, "nonlinear_solver", f"{root}.nonlinear_solver")
14192 nonlinear_path = f"{root}.nonlinear_solver"
14193 unknown = sorted(set(nonlinear) - {
14194 "method", "absolute_tolerance", "relative_tolerance", "step_tolerance",
14195 "max_iterations", "line_search", "eisenstat_walker",
14196 })
14197 if unknown:
14198 raise ValueError(f"{nonlinear_path} has unsupported key(s): {unknown}.")
14199 nonlinear_out = {}
14200 if "method" in nonlinear:
14201 nonlinear_out["method"] = _method(nonlinear["method"], f"{nonlinear_path}.method")
14202 for key in ("absolute_tolerance", "relative_tolerance", "step_tolerance"):
14203 if key in nonlinear:
14204 nonlinear_out[key] = _tolerance(nonlinear[key], f"{nonlinear_path}.{key}")
14205 if "max_iterations" in nonlinear:
14206 nonlinear_out["max_iterations"] = _positive_integer(
14207 nonlinear["max_iterations"], f"{nonlinear_path}.max_iterations"
14208 )
14209 if "line_search" in nonlinear:
14210 line_search = _mapping(nonlinear, "line_search", f"{nonlinear_path}.line_search")
14211 unknown = sorted(set(line_search) - {"type"})
14212 if unknown:
14213 raise ValueError(f"{nonlinear_path}.line_search has unsupported key(s): {unknown}.")
14214 nonlinear_out["line_search"] = {}
14215 if "type" in line_search:
14216 nonlinear_out["line_search"]["type"] = _method(
14217 line_search["type"], f"{nonlinear_path}.line_search.type"
14218 )
14219 if "eisenstat_walker" in nonlinear:
14220 ew_path = f"{nonlinear_path}.eisenstat_walker"
14221 ew = _mapping(nonlinear, "eisenstat_walker", ew_path)
14222 ew_keys = {
14223 "enabled", "version", "initial_relative_tolerance",
14224 "maximum_relative_tolerance", "gamma", "exponent",
14225 "safeguard_exponent", "safeguard_threshold",
14226 }
14227 unknown = sorted(set(ew) - ew_keys)
14228 if unknown:
14229 raise ValueError(f"{ew_path} has unsupported key(s): {unknown}.")
14230 enabled = ew.get("enabled", True)
14231 if not isinstance(enabled, bool):
14232 raise ValueError(f"{ew_path}.enabled must be boolean.")
14233 if not enabled and set(ew) - {"enabled"}:
14234 raise ValueError(f"{ew_path} parameters require enabled: true.")
14235 ew_out = {"enabled": enabled}
14236 if "version" in ew:
14237 version = ew["version"]
14238 if isinstance(version, bool) or not isinstance(version, int) or version not in {1, 2, 3, 4}:
14239 raise ValueError(f"{ew_path}.version must be one of 1, 2, 3, or 4.")
14240 ew_out["version"] = version
14241 bounds = {
14242 "initial_relative_tolerance": (0.0, 1.0, False, True),
14243 "maximum_relative_tolerance": (0.0, 1.0, False, True),
14244 "gamma": (0.0, 1.0, False, False),
14245 "exponent": (1.0, 2.0, True, False),
14246 "safeguard_threshold": (0.0, 1.0, True, True),
14247 }
14248 for key, (lower, upper, lower_open, upper_open) in bounds.items():
14249 if key not in ew:
14250 continue
14251 value = _tolerance(ew[key], f"{ew_path}.{key}")
14252 numeric = float(value)
14253 valid_lower = numeric > lower if lower_open else numeric >= lower
14254 valid_upper = numeric < upper if upper_open else numeric <= upper
14255 if not (valid_lower and valid_upper):
14256 brackets = ("(" if lower_open else "[") + f"{lower}, {upper}" + (")" if upper_open else "]")
14257 raise ValueError(f"{ew_path}.{key} must be in {brackets}.")
14258 ew_out[key] = value
14259 if "safeguard_exponent" in ew:
14260 ew_out["safeguard_exponent"] = _tolerance(
14261 ew["safeguard_exponent"], f"{ew_path}.safeguard_exponent"
14262 )
14263 nonlinear_out["eisenstat_walker"] = ew_out
14264 normalized["nonlinear_solver"] = nonlinear_out
14265
14266 linear = _mapping(cfg, "linear_solver", f"{root}.linear_solver")
14267 linear_path = f"{root}.linear_solver"
14268 unknown = sorted(set(linear) - {
14269 "method", "absolute_tolerance", "relative_tolerance", "max_iterations",
14270 "gmres", "preconditioner",
14271 })
14272 if unknown:
14273 raise ValueError(f"{linear_path} has unsupported key(s): {unknown}.")
14274 linear_out = {}
14275 method = "gmres"
14276 if "method" in linear:
14277 method = _method(linear["method"], f"{linear_path}.method")
14278 linear_out["method"] = method
14279 for key in ("absolute_tolerance", "relative_tolerance"):
14280 if key in linear:
14281 linear_out[key] = _tolerance(linear[key], f"{linear_path}.{key}")
14282 if "max_iterations" in linear:
14283 linear_out["max_iterations"] = _positive_integer(
14284 linear["max_iterations"], f"{linear_path}.max_iterations"
14285 )
14286 if "gmres" in linear:
14287 gmres = _mapping(linear, "gmres", f"{linear_path}.gmres")
14288 unknown = sorted(set(gmres) - {"restart"})
14289 if unknown:
14290 raise ValueError(f"{linear_path}.gmres has unsupported key(s): {unknown}.")
14291 linear_out["gmres"] = {}
14292 if "restart" in gmres:
14293 if method not in GMRES_RESTART_METHODS:
14294 raise ValueError(
14295 f"{linear_path}.gmres.restart is valid only when {linear_path}.method "
14296 "is one of 'gmres', 'fgmres', or 'lgmres'."
14297 )
14298 linear_out["gmres"]["restart"] = _positive_integer(
14299 gmres["restart"], f"{linear_path}.gmres.restart"
14300 )
14301 if "preconditioner" in linear:
14302 compatibility_pc = _mapping(linear, "preconditioner", f"{linear_path}.preconditioner")
14303 unknown = sorted(set(compatibility_pc) - {"type"})
14304 if unknown:
14305 raise ValueError(f"{linear_path}.preconditioner has unsupported key(s): {unknown}.")
14306 if "type" in compatibility_pc:
14307 pc_type = _method(compatibility_pc["type"], f"{linear_path}.preconditioner.type")
14308 if pc_type != "none":
14309 raise ValueError(
14310 f"{linear_path}.preconditioner.type is a deprecated compatibility alias "
14311 "and supports only 'none'."
14312 )
14313 if "preconditioner" in cfg and model != "none":
14314 raise ValueError(
14315 f"{linear_path}.preconditioner.type 'none' conflicts with "
14316 f"{preconditioner_path}.model '{model}'."
14317 )
14318 warnings.warn(
14319 f"{linear_path}.preconditioner.type is deprecated; use "
14320 f"{preconditioner_path}.model: none.",
14321 FutureWarning,
14322 stacklevel=3,
14323 )
14324 normalized["linear_solver"] = linear_out
14325 return normalized
14326
14328 """!
14329 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
14330 @param[in] value Human-readable solution-convergence mode selector.
14331 @return Canonical string accepted by `-solution_convergence_mode`.
14332 @throws ValueError if the input cannot be mapped.
14333 """
14334 if value is None:
14335 raise ValueError("solution_convergence.mode cannot be None")
14336
14337 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
14338 aliases = {
14339 "steady_deterministic": "STEADY_DETERMINISTIC",
14340 "periodic_deterministic": "PERIODIC_DETERMINISTIC",
14341 "statistical_steady": "STATISTICAL_STEADY",
14342 "transient": "TRANSIENT",
14343 }
14344 mapped = aliases.get(normalized)
14345 if mapped is None:
14346 raise ValueError(
14347 f"Unknown solution_convergence.mode '{value}'. Use one of: "
14348 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
14349 )
14350 return mapped
14351
14352def normalize_field_init_mode(value: str) -> int:
14353 """!
14354 @brief Maps canonical field init mode names to C enum/int codes (-finit).
14355 @param[in] value Canonical field initialization mode.
14356 @return Canonical integer code accepted by -finit.
14357 @throws ValueError if the input cannot be mapped.
14358 """
14359 # Canonical selector map for field initialization modes.
14360 if value is None:
14361 raise ValueError("field initialization mode cannot be None")
14362
14363 mapped = {
14364 "Zero": 0,
14365 "Constant": 1,
14366 "Poiseuille": 2,
14367 }.get(str(value).strip())
14368 if mapped is None:
14369 raise ValueError(
14370 f"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
14371 )
14372 return mapped
14373
14374def normalize_initial_condition_field(value: str) -> "tuple[str, int]":
14375 """!
14376 @brief Normalize a file IC field selector to its staged basename and C enum value.
14377 @param[in] value User-facing Ucat or Ucont selector.
14378 @return Tuple of staged field basename and C enum value.
14379 """
14380 normalized = str(value or "").strip().lower()
14381 if normalized == "ucat":
14382 return "ufield", 0
14383 if normalized == "ucont":
14384 return "vfield", 1
14385 raise ValueError("initial_conditions.field must be 'Ucat' or 'Ucont'.")
14386
14387
14388#: Physical dimension of a staged initial-condition payload, by C field code.
14389IC_PAYLOAD_DIMENSIONS = {0: VELOCITY, 1: VOLUME_FLUX}
14390
14391
14392def resolve_ic_payload_provenance(ic: dict, field_code: int) -> dict:
14393 """!
14394 @brief Resolve which scales a file-backed initial condition was written in.
14395 @details A payload is physical unless it declares otherwise. A field saved by an
14396 earlier run is in that run's solver units, so it names the case that wrote
14397 it (`source_case`) or gives its scales directly, the same way a
14398 `field_slice` inlet profile does.
14399 @param[in] ic Initial-condition YAML mapping.
14400 @param[in] field_code C field code: 0 for Ucat, 1 for Ucont.
14401 @return Mapping with `source_case`, or `velocity_scale` and `length_scale`, or empty.
14402 @throws ValueError on a conflicting, incomplete, or invalid declaration.
14403 """
14404 source_case = ic.get("source_case")
14405 has_scale = any(key in ic for key in ("velocity_scale", "length_scale"))
14406 if source_case is not None:
14407 if has_scale:
14408 raise ValueError("initial_conditions: give source_case or velocity_scale/length_scale, not both.")
14409 if not isinstance(source_case, str) or not source_case.strip():
14410 raise ValueError("initial_conditions.source_case must be a non-empty path.")
14411 return {"source_case": source_case.strip()}
14412 if not has_scale:
14413 return {}
14414 if "velocity_scale" not in ic:
14415 raise ValueError("initial_conditions.length_scale requires velocity_scale.")
14416 provenance = {"velocity_scale": _to_finite_float(ic["velocity_scale"], "initial_conditions.velocity_scale"),
14417 "length_scale": 1.0}
14418 if field_code == 1:
14419 if "length_scale" not in ic:
14420 raise ValueError("initial_conditions.length_scale is required for a Ucont payload in solver units.")
14421 provenance["length_scale"] = _to_finite_float(ic["length_scale"], "initial_conditions.length_scale")
14422 elif "length_scale" in ic:
14423 raise ValueError("initial_conditions.length_scale applies only to a Ucont payload.")
14424 if provenance["velocity_scale"] <= 0.0 or provenance["length_scale"] <= 0.0:
14425 raise ValueError("initial_conditions.velocity_scale and length_scale must be positive.")
14426 return provenance
14427
14428
14429def ic_payload_divisor(resolved_ic: dict, case_dir: str) -> float:
14430 """!
14431 @brief Return the factor a file-backed initial condition is divided by when staged.
14432 @details A physical payload is divided by its dimension's reference scale. A payload in
14433 another run's solver units is first returned to physical units by that run's
14434 scale, so the two factors combine into one ratio.
14435 @param[in] resolved_ic Resolved `mode: file` initial condition.
14436 @param[in] case_dir Directory that relative `source_case` paths resolve against.
14437 @return Positive divisor; 1.0 when the payload already matches this case's solver units.
14438 """
14439 dimension = IC_PAYLOAD_DIMENSIONS[resolved_ic["field_code"]]
14440 provenance = resolved_ic.get("payload_provenance") or {}
14441 source_scales = {"length_ref": 1.0, "velocity_ref": 1.0}
14442 if "source_case" in provenance:
14443 source_path = _resolve_case_relative_path(provenance["source_case"], case_dir)
14444 source_scales = resolve_reference_scales(read_yaml_file(source_path))
14445 elif provenance:
14446 source_scales = {"length_ref": provenance["length_scale"], "velocity_ref": provenance["velocity_scale"]}
14447 return reference_scale(dimension, resolved_ic["scales"]) / reference_scale(dimension, source_scales)
14448
14449GENERATED_IC_PROVIDERS = {
14450 "channel_spectral_velocity": {"requires_grid": True, "requires_fresh_3d": True},
14451 "duct_spectral_velocity": {"requires_grid": True, "requires_fresh_3d": True},
14452 "stability_mode_velocity": {"requires_grid": True},
14453 "ic_gen": {
14454 "requires_grid": True,
14455 "diagnostic_artifacts": (),
14456 },
14457 "spectral_random_velocity": {
14458 "requires_grid": True,
14459 "requires_periodic_geometric": True,
14460 "requires_fresh_3d": True,
14461 "diagnostic_artifacts": (
14462 ("summary_json", os.path.join(CANONICAL_RUN_PATHS["metrics"], "initial_condition_summary.json")),
14463 ("spectrum_csv", INITIAL_CONDITION_SPECTRUM_RELPATH),
14464 ),
14465 },
14466}
14467GENERATED_IC_PROVIDERS["resampled_velocity"] = GENERATED_IC_PROVIDERS["spectral_random_velocity"]
14468
14469
14470def is_generated_ic_provider(resolved_ic: dict) -> bool:
14471 """!
14472 @brief Return whether a resolved IC is backed by a registered file generator.
14473 @param[in] resolved_ic Resolved initial-condition contract.
14474 @return True for a registered generated-file provider.
14475 """
14476 return resolved_ic.get("kind") in GENERATED_IC_PROVIDERS
14477
14478
14479def resolve_fluid_scaling(case_cfg: dict) -> dict:
14480 """!
14481 @brief Resolve the shared physical and nondimensional fluid scaling contract.
14482 @param[in] case_cfg Parsed case configuration.
14483 @return Resolved scaling, viscosity, and Reynolds-number quantities.
14484 """
14485 properties = case_cfg["properties"]
14486 scaling = properties["scaling"]
14487 fluid = properties["fluid"]
14488 length_ref = _to_finite_float(scaling["length_ref"], "properties.scaling.length_ref")
14489 velocity_ref = _to_finite_float(scaling["velocity_ref"], "properties.scaling.velocity_ref")
14490 density = _to_finite_float(fluid["density"], "properties.fluid.density")
14491 dynamic_viscosity = _to_finite_float(fluid["viscosity"], "properties.fluid.viscosity")
14492 if length_ref <= 0.0 or velocity_ref <= 0.0 or density <= 0.0 or dynamic_viscosity < 0.0:
14493 raise ValueError("length_ref, velocity_ref, and density must be positive; viscosity must be non-negative.")
14494 reynolds = density*velocity_ref*length_ref/dynamic_viscosity if dynamic_viscosity else float("inf")
14495 return {
14496 "length_ref": length_ref, "velocity_ref": velocity_ref, "density": density,
14497 "dynamic_viscosity": dynamic_viscosity, "reynolds": reynolds,
14498 "physical_kinematic_viscosity": dynamic_viscosity/density,
14499 "nondimensional_kinematic_viscosity": dynamic_viscosity/(density*velocity_ref*length_ref),
14500 }
14501
14502
14503def load_generator_module(script_name: str, module_name: str):
14504 """!
14505 @brief Load one of the extensionless scripts under `generators/` as a module.
14506 @param[in] script_name File name under `generators/`, e.g. `ic.gen`.
14507 @param[in] module_name Name to register the loaded module under.
14508 @return Loaded module.
14509 """
14510 from importlib.machinery import SourceFileLoader
14511 loader = SourceFileLoader(module_name, os.path.join(GENERATORS_PATH, script_name))
14512 spec = importlib.util.spec_from_loader(loader.name, loader)
14513 module = importlib.util.module_from_spec(spec)
14514 loader.exec_module(module)
14515 return module
14516
14517
14519 """!
14520 @brief Load `generators/ic.gen`, which owns the expression language and the IC providers.
14521 @return Loaded module.
14522 """
14523 return load_generator_module("ic.gen", "picurv_ic_generator")
14524
14525
14526#: Particle fields a case may give an initial value, with their component counts: the
14527#: fields the particle carries (catalog capability `USER_INITIALIZE`). Every other particle
14528#: field is re-derived from the Eulerian fields or from particle location, so a configured
14529#: value would not survive. `tests/test_particle_fields.py` checks this against the catalog.
14530PARTICLE_SETTABLE_FIELDS = {"Psi": 1}
14531
14532
14533def normalize_particle_fields(fields_cfg) -> list:
14534 """!
14535 @brief Validate `models.physics.particles.fields` and lower each value to expressions.
14536 @details A value is a number, an expression, or a mapping with `params` and either
14537 `value` or `background` with `regions`; a field with several components takes a
14538 list of values, one per component. Expressions use physical coordinates
14539 `x y z`, domain-normalized `xn yn zn`, `pid`, and physical time `t`, and may
14540 draw with `uniform()`/`normal()`.
14541 @param[in] fields_cfg The `fields` mapping.
14542 @return List of `{"name", "expressions"}` in configuration order.
14543 @throws ValueError on an unknown field, a wrong component count, or an invalid value.
14544 """
14545 if not isinstance(fields_cfg, dict) or not fields_cfg:
14546 raise ValueError("models.physics.particles.fields must be a non-empty mapping of field names to values.")
14547 ic_gen = load_ic_generator_module()
14548 normalized = []
14549 for name, spec in fields_cfg.items():
14550 if name not in PARTICLE_SETTABLE_FIELDS:
14551 raise ValueError(
14552 f"models.physics.particles.fields.{name}: not a field a case can set; the runtime "
14553 f"re-derives it. Settable fields: {sorted(PARTICLE_SETTABLE_FIELDS)}.")
14554 components = PARTICLE_SETTABLE_FIELDS[name]
14555 specs = spec if components > 1 else [spec]
14556 if not isinstance(specs, list) or len(specs) != components:
14557 raise ValueError(f"models.physics.particles.fields.{name} takes {components} component values.")
14558 expressions = [
14559 ic_gen.lower_value(item, ic_gen.PARTICLE_EXPRESSION_NAMES, random=True,
14560 where=f"models.physics.particles.fields.{name}")
14561 for item in specs
14562 ]
14563 normalized.append({"name": name, "expressions": expressions})
14564 return normalized
14565
14566
14567def ic_provider_params_to_solver_units(generator: str, params: dict, scales: dict) -> dict:
14568 """!
14569 @brief Convert a provider's validated physical parameters to solver units.
14570 @details Providers work on the staged nondimensional grid, so every dimensional
14571 parameter recorded in `IC_PARAM_QUANTITIES` is converted before the provider
14572 runs. Nested mappings are addressed by dotted name.
14573 @param[in] generator Provider identifier.
14574 @param[in] params Validated parameter mapping in physical units.
14575 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
14576 @return A copy of the mapping with dimensional values in solver units.
14577 """
14578 converted = copy.deepcopy(params)
14579 for name, quantity in IC_PARAM_QUANTITIES[generator].items():
14580 if quantity[1] != "cli":
14581 continue
14582 *parents, leaf = name.split(".")
14583 holder = converted
14584 for parent in parents:
14585 holder = holder.get(parent) if isinstance(holder, dict) else None
14586 if isinstance(holder, dict) and leaf in holder:
14587 holder[leaf] = quantity_to_solver_units(
14588 holder[leaf], quantity, scales, f"initial_conditions.params.{name}")
14589 return converted
14590
14591
14592def resolve_resampled_velocity_params(params: dict, prepared_blocks, scales: dict, provider_context=None) -> dict:
14593 """!
14594 @brief Validate `resampled_velocity` parameters and convert them to the provider contract.
14595 @details The provider brings an external triply periodic velocity field onto the case's
14596 grid. Its box must be the case's box; the source's container, layout, and sample
14597 positions are declared because no standard format records them all.
14598 @param[in] params Raw provider parameters, physical units.
14599 @param[in] prepared_blocks Normalized boundary-condition blocks.
14600 @param[in] scales Reference scales.
14601 @param[in] provider_context Optional conductor-derived provider context.
14602 @return Resolved initial-condition contract; `source_file` stays as written and is
14603 resolved against the workspace when the provider runs.
14604 """
14605 name = "resampled_velocity"
14606 if prepared_blocks and len(prepared_blocks) != 1:
14607 raise ValueError(f"{name} requires exactly one grid block.")
14608 if not prepared_blocks or len(prepared_blocks[0]) != 6 or any(
14609 bc.get("type") != "PERIODIC" or bc.get("handler") != "geometric" for bc in prepared_blocks[0]
14610 ):
14611 raise ValueError(f"{name} requires PERIODIC/geometric boundaries on all six faces; "
14612 "wall-bounded sources are not supported yet.")
14613 unknown = sorted(set(params) - RESAMPLED_VELOCITY_PARAMS)
14614 if unknown:
14615 raise ValueError(f"{name} has unsupported params: {unknown}.")
14616 # Ucont is the default: face-centre samples projected with PICurv's face-difference
14617 # operator keep the most energy through the runtime's reconstruction and need the
14618 # smallest projection; Ucat stages cell centres like the synthetic providers do.
14619 field_name, field_code = normalize_initial_condition_field(params.get("field", "Ucont"))
14620 source_file = params.get("source_file")
14621 if not isinstance(source_file, str) or not source_file.strip():
14622 raise ValueError(f"{name} requires params.source_file, the field to import.")
14623 fmt = str(params.get("format", "")).lower()
14624 if fmt not in RESAMPLED_VELOCITY_LAYOUT_KEYS:
14625 raise ValueError(f"{name} params.format must be one of {sorted(RESAMPLED_VELOCITY_LAYOUT_KEYS)}.")
14626 cells = params.get("cells")
14627 if cells is not None:
14628 if (not isinstance(cells, list) or len(cells) != 3
14629 or any(isinstance(c, bool) or not isinstance(c, int) or c < 4 for c in cells)):
14630 raise ValueError(f"{name} params.cells must list the source's sample counts along x, y, z, "
14631 "each an integer of at least 4.")
14632 elif fmt == "raw":
14633 raise ValueError(f"{name} with format: raw requires params.cells, the sample counts along x, y, z.")
14634 box = params.get("box_length")
14635 if not isinstance(box, list) or len(box) != 3:
14636 raise ValueError(f"{name} requires params.box_length: the source's periods along x, y, z, "
14637 "in physical length.")
14638 box = [_to_finite_float(value, f"initial_conditions.params.box_length[{index}]")
14639 for index, value in enumerate(box)]
14640 if any(value <= 0 for value in box):
14641 raise ValueError(f"{name} params.box_length values must be positive.")
14642 offset = params.get("sample_offset", 0.0)
14643 offset = offset if isinstance(offset, list) else [offset, offset, offset]
14644 if len(offset) != 3:
14645 raise ValueError(f"{name} params.sample_offset is one number or three, along x, y, z.")
14646 offset = [_to_finite_float(value, f"initial_conditions.params.sample_offset[{index}]")
14647 for index, value in enumerate(offset)]
14648 if any(value < 0.0 or value >= 1.0 for value in offset):
14649 raise ValueError(f"{name} params.sample_offset is a fraction of the source spacing in [0, 1): "
14650 "0 for samples on the box origin, 0.5 for cell centres.")
14651 layout = params.get("layout", {})
14652 if not isinstance(layout, dict):
14653 raise ValueError(f"{name} params.layout must be a mapping.")
14654 extra = sorted(set(layout) - RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt])
14655 if extra:
14656 raise ValueError(f"{name} layout keys {extra} do not apply to format: {fmt}; "
14657 f"it accepts {sorted(RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt])}.")
14658 normalized_layout = {"fastest_axis": str(layout.get("fastest_axis", "x")).lower(),
14659 "components": str(layout.get("components", "interleaved")).lower()}
14660 if normalized_layout["fastest_axis"] not in RESAMPLED_VELOCITY_FASTEST_AXES:
14661 raise ValueError(f"{name} layout.fastest_axis must be x (x varies fastest) or z.")
14662 if normalized_layout["components"] not in RESAMPLED_VELOCITY_COMPONENT_LAYOUTS:
14663 raise ValueError(f"{name} layout.components must be interleaved (u, v, w at each point) "
14664 "or separate (all u, then all v, then all w).")
14665 if fmt == "raw":
14666 dtype = str(layout.get("dtype", "")).lower()
14667 if dtype not in RESAMPLED_VELOCITY_RAW_DTYPES:
14668 raise ValueError(f"{name} format: raw requires layout.dtype: float32 or float64.")
14669 byte_order = str(layout.get("byte_order", "native")).lower()
14670 if byte_order not in RESAMPLED_VELOCITY_BYTE_ORDERS:
14671 raise ValueError(f"{name} layout.byte_order must be little, big, or native.")
14672 header = layout.get("header_bytes", 0)
14673 if isinstance(header, bool) or not isinstance(header, int) or header < 0:
14674 raise ValueError(f"{name} layout.header_bytes must be a non-negative integer.")
14675 normalized_layout.update({"dtype": dtype, "byte_order": byte_order, "header_bytes": header})
14676 elif "datasets" in RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt]:
14677 datasets = layout.get("datasets")
14678 if (not isinstance(datasets, list) or len(datasets) not in (1, 3)
14679 or any(not isinstance(item, str) or not item for item in datasets)):
14680 raise ValueError(f"{name} format: {fmt} requires layout.datasets: one array name, "
14681 "or three for u, v, w.")
14682 normalized_layout["datasets"] = list(datasets)
14683 filter_cfg = params.get("filter", {"type": "none"})
14684 if not isinstance(filter_cfg, dict):
14685 raise ValueError(f"{name} params.filter must be a mapping.")
14686 filter_type = str(filter_cfg.get("type", "none")).lower()
14687 expected = {"none": set(), "sharp": {"cutoff"}, "gaussian": {"width"}, "box": {"width"}}
14688 if filter_type not in expected:
14689 raise ValueError(f"{name} filter.type must be one of {sorted(expected)}.")
14690 if set(filter_cfg) - {"type"} != expected[filter_type]:
14691 raise ValueError(f"{name} filter.type: {filter_type} takes exactly "
14692 f"{sorted(expected[filter_type]) or 'no parameters'}.")
14693 normalized_filter = {"type": filter_type}
14694 for key in expected[filter_type]:
14695 value = _to_finite_float(filter_cfg[key], f"initial_conditions.params.filter.{key}")
14696 if value <= 0:
14697 raise ValueError(f"{name} filter.{key} must be positive.")
14698 normalized_filter[key] = value
14699 projection = str(params.get("projection", "picurv_discrete")).lower()
14700 if projection not in RESAMPLED_VELOCITY_PROJECTIONS:
14701 raise ValueError(f"{name} params.projection must be picurv_discrete, continuum, or none.")
14702 remove_mean = params.get("remove_mean", True)
14703 if not isinstance(remove_mean, bool):
14704 raise ValueError(f"{name} params.remove_mean must be boolean.")
14705 normalized = {"field": "Ucont" if field_code == 1 else "Ucat", "source_file": source_file,
14706 "format": fmt, "box_length": box,
14707 "sample_offset": offset, "layout": normalized_layout, "filter": normalized_filter,
14708 "projection": projection, "remove_mean": remove_mean}
14709 if cells is not None:
14710 normalized["cells"] = list(cells)
14711 resolved = ic_provider_params_to_solver_units(name, normalized, scales)
14712 resolved["velocity_divisor"] = float(scales["velocity_ref"])
14713 return {"finit": 4, "cli_params": {}, "kind": name, "label": name,
14714 "field_name": field_name, "field_code": field_code,
14715 "provider_context": dict(provider_context or {}), "params": resolved}
14716
14717
14718def normalize_ic_initial_spectra(requested, excluded_axes, generator, required):
14719 """!
14720 @brief Validate the spectra a structured IC provider measures on its own output.
14721 @param[in] requested `params.initial_spectra` as written, or None.
14722 @param[in] excluded_axes Axes the spectra must not transform (walls, cross-stream).
14723 @param[in] generator Provider identifier, for messages.
14724 @param[in] required Whether a nonempty list is mandatory.
14725 @return Normalized spectrum tasks (an empty list when optional and absent).
14726 @throws ValueError on an absent required list or an unsupported task.
14727 """
14728 if requested is None and not required:
14729 return []
14730 if not isinstance(requested, list) or not requested:
14731 raise ValueError(f"{generator} requires params.initial_spectra: a nonempty list of "
14732 "plane_spectrum or line_spectrum tasks over the periodic axes.")
14733 spectra = normalize_post_spectra_config({"spectra": {"tasks": requested}})
14734 for task in spectra["tasks"]:
14735 if task["task"] == "shell_spectrum" or set(task["axes"]) & set(excluded_axes):
14736 raise ValueError(f"initial spectra must transform only periodic axes of the {generator} IC.")
14737 if task["block"] != 0 or task["subtract_mean"].startswith("window:") or task["reference"]:
14738 raise ValueError("initial spectra require block 0, no window mean, and no reference.")
14739 return spectra["tasks"]
14740
14741
14742def resolve_initial_condition_config(ic: dict, prepared_blocks, scales: dict, provider_context=None) -> dict:
14743 """!
14744 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
14745 @details Every value the user writes is physical; the resolved contract carries solver
14746 units, or the scales a provider or the staging step needs to reach them.
14747 @param[in] ic Initial-condition YAML mapping.
14748 @param[in] prepared_blocks Normalized boundary-condition blocks.
14749 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
14750 @param[in] provider_context Optional conductor-derived provider context.
14751 @return Normalized launcher initial-condition contract.
14752 """
14753 if not isinstance(ic, dict):
14754 raise ValueError("properties.initial_conditions must be a mapping.")
14755 mode = str(ic.get("mode", "")).strip()
14756
14757 # Backward-compatible legacy spelling.
14758 if mode in LEGACY_FIELD_INIT_SPELLINGS:
14759 finit_code = normalize_field_init_mode(mode)
14760 params = resolve_ic_cli_params(ic, finit_code, prepared_blocks, scales)
14761 if finit_code == 1 and params.pop("ic_coordinate_system", 0) == 1:
14762 finit_code = 3
14763 return {"finit": finit_code, "cli_params": params, "kind": "builtin", "label": mode}
14764
14765 normalized_mode = mode.lower().replace("-", "_").replace(" ", "_")
14766 if normalized_mode == "file":
14767 if prepared_blocks and len(prepared_blocks) > 1:
14768 raise ValueError("File-backed initial conditions currently support single-block cases only.")
14769 source_file = ic.get("source_file")
14770 if not isinstance(source_file, str) or not source_file.strip():
14771 raise ValueError("initial_conditions.source_file is required when mode is 'file'.")
14772 field_name, field_code = normalize_initial_condition_field(ic.get("field"))
14773 return {
14774 "finit": 4, "cli_params": {}, "kind": "file", "label": "file",
14775 "source_file": source_file.strip(), "field_name": field_name, "field_code": field_code,
14776 "payload_provenance": resolve_ic_payload_provenance(ic, field_code),
14777 "scales": dict(scales),
14778 }
14779 if normalized_mode != "generated":
14780 raise ValueError("initial_conditions.mode must be 'generated' or 'file'.")
14781
14782 generator = str(ic.get("generator", "")).strip().lower().replace("-", "_").replace(" ", "_")
14783 params = ic.get("params", {})
14784 if not isinstance(params, dict):
14785 raise ValueError("initial_conditions.params must be a mapping.")
14786 for key in RETIRED_INITIAL_CONDITION_DESTINATION_KEYS:
14787 if key in params:
14788 raise ValueError(retired_destination_message("initial_conditions.params", key))
14789 if generator == "ic_gen":
14790 if prepared_blocks and len(prepared_blocks) > 1:
14791 raise ValueError("File-backed initial conditions currently support single-block cases only.")
14792 script = params.get("script")
14793 if script is not None and (not isinstance(script, str) or not script.strip()):
14794 raise ValueError("initial_conditions.params.script must be a non-empty path when provided.")
14795 field_name, field_code = normalize_initial_condition_field(params.get("field"))
14796 config_file = params.get("config_file")
14797 if not isinstance(config_file, str) or not config_file.strip():
14798 raise ValueError("initial_conditions.params.config_file is required for generator 'ic_gen'.")
14799 cli_args = params.get("cli_args", [])
14800 if cli_args is None:
14801 cli_args = []
14802 if not isinstance(cli_args, list):
14803 raise ValueError("initial_conditions.params.cli_args must be a list.")
14804 return {
14805 "finit": 4, "cli_params": {}, "kind": "ic_gen", "label": "ic_gen",
14806 "field_name": field_name, "field_code": field_code,
14807 "config_file": config_file.strip(),
14808 "script": script.strip() if script is not None else None,
14809 "cli_args": cli_args,
14810 "scales": dict(scales),
14811 }
14812
14813 if generator in ("channel_spectral_velocity", "duct_spectral_velocity"):
14814 normalized = load_ic_generator_module().validate_wall_spectral_params(params, generator)
14815 if not prepared_blocks or len(prepared_blocks) != 1:
14816 raise ValueError("wall spectral IC requires exactly one block with resolved boundaries.")
14817 face_axes = {"Xi": "i", "Eta": "j", "Zeta": "k"}
14818 faces = prepared_blocks[0]
14819 if len(faces) != 6:
14820 raise ValueError("wall spectral IC requires all six boundary faces.")
14821 for bc in faces:
14822 axis = face_axes[bc["face"][1:]]
14823 if axis in normalized["wall_axes"]:
14824 # The perturbation vanishes at the wall, which every wall handler admits.
14825 valid = bc["type"] == "WALL" and "WALL" in BC_HANDLER_SPECS.get(bc["handler"], {}).get("types", ())
14826 else:
14827 valid = bc["type"] == "PERIODIC" and bc["handler"] in ("geometric", "constant_flux", "initial_flux")
14828 if axis != normalized["streamwise_axis"]:
14829 valid = valid and bc["handler"] == "geometric"
14830 if not valid:
14831 raise ValueError("wall spectral IC requires wall pairs (no-slip or free-slip) and periodic remaining axes, driven only streamwise.")
14832 normalized["initial_spectra"] = normalize_ic_initial_spectra(
14833 normalized.get("initial_spectra"), normalized["wall_axes"], generator, required=True)
14834 return {"finit": 4, "cli_params": {}, "kind": generator, "label": generator,
14835 "field_name": "ufield", "field_code": 0,
14836 "params": ic_provider_params_to_solver_units(generator, normalized, scales),
14837 "provider_context": dict(provider_context or {})}
14838
14839 if generator == "stability_mode_velocity":
14840 normalized = load_ic_generator_module().validate_stability_mode_params(params)
14841 if not prepared_blocks or len(prepared_blocks) != 1:
14842 raise ValueError("stability_mode_velocity requires exactly one block with resolved boundaries.")
14843 face_axes = {"Xi": "i", "Eta": "j", "Zeta": "k"}
14844 faces = prepared_blocks[0]
14845 if len(faces) != 6:
14846 raise ValueError("stability_mode_velocity requires all six boundary faces.")
14847 for bc in faces:
14848 cross = face_axes[bc["face"][1:]] == normalized["cross_axis"]
14849 if cross == (bc["type"] == "PERIODIC") or (not cross and bc["handler"] != "geometric"):
14850 raise ValueError("stability_mode_velocity requires geometric periodic streamwise and normal axes "
14851 "and non-periodic cross-stream faces.")
14852 if "initial_spectra" in normalized:
14853 normalized["initial_spectra"] = normalize_ic_initial_spectra(
14854 normalized["initial_spectra"], [normalized["cross_axis"]], generator, required=False)
14855 context = dict(provider_context or {})
14856 # The base profile is an expression of physical quantities; the provider applies these.
14857 context.update({"length_ref": scales["length_ref"], "velocity_ref": scales["velocity_ref"]})
14858 return {"finit": 4, "cli_params": {}, "kind": generator, "label": generator,
14859 "field_name": "ufield", "field_code": 0,
14860 "params": ic_provider_params_to_solver_units(generator, normalized, scales),
14861 "provider_context": context}
14862
14863 if generator == "resampled_velocity":
14864 return resolve_resampled_velocity_params(params, prepared_blocks, scales, provider_context)
14865
14866 if generator == "spectral_random_velocity":
14867 if prepared_blocks and len(prepared_blocks) != 1:
14868 raise ValueError("spectral_random_velocity requires exactly one grid block.")
14869 if not prepared_blocks or len(prepared_blocks[0]) != 6 or any(
14870 bc.get("type") != "PERIODIC" or bc.get("handler") != "geometric"
14871 for bc in prepared_blocks[0]
14872 ):
14873 raise ValueError("spectral_random_velocity requires PERIODIC/geometric boundaries on all six faces.")
14874 unknown = sorted(set(params) - SPECTRAL_RANDOM_VELOCITY_PARAMS)
14875 if unknown:
14876 raise ValueError(f"spectral_random_velocity has unsupported params: {unknown}.")
14877 field_name, field_code = normalize_initial_condition_field(params.get("field", "Ucat"))
14878 if field_code != 0:
14879 raise ValueError("spectral_random_velocity supports only params.field: Ucat.")
14880 seed = params.get("seed", 12345)
14881 if isinstance(seed, bool) or not isinstance(seed, int):
14882 raise ValueError("spectral_random_velocity params.seed must be an integer.")
14883 random_cfg = params.get("random", {})
14884 if not isinstance(random_cfg, dict) or set(random_cfg) - {"distribution", "mean"}:
14885 raise ValueError("spectral_random_velocity params.random supports only distribution and mean.")
14886 distribution = str(random_cfg.get("distribution", "gaussian")).lower()
14887 if distribution != "gaussian":
14888 raise ValueError("spectral_random_velocity supports only random.distribution: gaussian.")
14889 mean = random_cfg.get("mean", [0.0, 0.0, 0.0])
14890 if not isinstance(mean, list) or len(mean) != 3:
14891 raise ValueError("spectral_random_velocity random.mean must be a three-component list.")
14892 mean = [_to_finite_float(value, f"initial_conditions.params.random.mean[{index}]")
14893 for index, value in enumerate(mean)]
14894 spectrum = params.get("spectrum", {})
14895 if not isinstance(spectrum, dict):
14896 raise ValueError("spectral_random_velocity params.spectrum must be a mapping.")
14897 spectrum_type = str(spectrum.get("type", "white")).lower()
14898 if spectrum_type == "white":
14899 if set(spectrum) - {"type"}:
14900 raise ValueError("white spectrum supports only type.")
14901 normalized_spectrum = {"type": "white"}
14902 elif spectrum_type == "k4_exponential":
14903 if set(spectrum) - {"type", "k0", "k_cut"} or "k0" not in spectrum or "k_cut" not in spectrum:
14904 raise ValueError("k4_exponential spectrum requires only type, k0, and k_cut.")
14905 normalized_spectrum = {"type": "k4_exponential",
14906 "k0": _to_finite_float(spectrum["k0"], "initial_conditions.params.spectrum.k0"),
14907 "k_cut": _to_finite_float(spectrum["k_cut"], "initial_conditions.params.spectrum.k_cut")}
14908 else:
14909 raise ValueError("spectrum.type must be 'white' or 'k4_exponential'.")
14910 if any(value <= 0 for key, value in normalized_spectrum.items() if key != "type"):
14911 raise ValueError("spectrum k0 and k_cut values must be positive.")
14912 projection = params.get("projection", {"type": "none"})
14913 if not isinstance(projection, dict) or set(projection) - {"type", "operator"}:
14914 raise ValueError("projection supports only type and operator.")
14915 projection_type = str(projection.get("type", "none")).lower()
14916 if projection_type == "none" and "operator" not in projection:
14917 normalized_projection = {"type": "none"}
14918 elif projection_type == "solenoidal" and str(projection.get("operator", "")).lower() in PROJECTION_OPERATORS:
14919 normalized_projection = {"type": "solenoidal", "operator": str(projection["operator"]).lower()}
14920 else:
14921 raise ValueError("projection must be none, or solenoidal with continuum/picurv_discrete operator.")
14922 normalization = params.get("normalization", {"type": "none"})
14923 if not isinstance(normalization, dict) or set(normalization) - {"type", "target"}:
14924 raise ValueError("normalization supports only type and target.")
14925 normalization_type = str(normalization.get("type", "none")).lower()
14926 if normalization_type == "none" and "target" not in normalization:
14927 normalized_normalization = {"type": "none"}
14928 elif normalization_type == "component_rms" and "target" in normalization:
14929 target = _to_finite_float(normalization["target"], "initial_conditions.params.normalization.target")
14930 if target <= 0:
14931 raise ValueError("component_rms normalization.target must be positive.")
14932 normalized_normalization = {"type": "component_rms", "target": target}
14933 else:
14934 raise ValueError("normalization must be none or component_rms with target.")
14935 remove_mean = params.get("remove_mean", True)
14936 if not isinstance(remove_mean, bool):
14937 raise ValueError("spectral_random_velocity remove_mean must be boolean.")
14938 context = dict(provider_context or {})
14939 return {
14940 "finit": 4, "cli_params": {}, "kind": "spectral_random_velocity", "label": "spectral_random_velocity",
14941 "field_name": field_name, "field_code": field_code, "provider_context": context,
14943 "spectral_random_velocity",
14944 {"field": "Ucat", "seed": seed,
14945 "random": {"distribution": distribution, "mean": mean},
14946 "spectrum": normalized_spectrum, "projection": normalized_projection,
14947 "normalization": normalized_normalization, "remove_mean": remove_mean},
14948 scales),
14949 }
14950
14951 generator_modes = {
14952 "zero": (0, "Zero"),
14953 "constant": (1, "Constant"),
14954 "streamwise_constant": (3, "Constant"),
14955 "poiseuille": (2, "Poiseuille"),
14956 }
14957 if generator not in generator_modes:
14958 raise ValueError(
14959 "initial_conditions.generator must be one of: zero, constant, "
14960 "streamwise_constant, poiseuille, ic_gen, spectral_random_velocity, resampled_velocity, "
14961 "channel_spectral_velocity, duct_spectral_velocity, stability_mode_velocity."
14962 )
14963 finit_code, legacy_mode = generator_modes[generator]
14964 legacy_ic = dict(params)
14965 legacy_ic["mode"] = legacy_mode
14966 cli_params = resolve_ic_cli_params(
14967 legacy_ic,
14968 1 if finit_code == 3 else finit_code,
14969 prepared_blocks,
14970 scales,
14971 )
14972 cli_params.pop("ic_coordinate_system", None)
14973 return {"finit": finit_code, "cli_params": cli_params, "kind": "builtin", "label": generator}
14974
14976 """!
14977 @brief Bytes of a PetscInt in the PETSc the solver this conductor drives was built on.
14978 @details PETSc binary files store their sizes as PetscInt and do not record the width, so
14979 a file staged for the solver must use the solver's (PETSc's own PetscBinaryIO
14980 reads it from the build configuration the same way). An unbuilt or unstamped
14981 solver counts as 32-bit, PETSc's default.
14982 @return 4 or 8.
14983 """
14984 if _SELECTED_PETSC_INDEX_BYTES is not None:
14985 return _SELECTED_PETSC_INDEX_BYTES
14986 petsc = read_binary_petsc_stamp(resolve_runtime_executable("simulator")) or {}
14987 return PETSC_INDEX_BYTES.get(petsc.get("indices", "int32"), 4)
14988
14989
14990#: PetscInt widths, in bits, a PICurv build can use (`make INDEX_BITS=...`, `--petsc-index-bits`).
14991PETSC_INDEX_BITS_CHOICES = ("32", "64")
14992#: Largest PetscInt of a 32-bit-index PETSc; any one object past it needs the 64-bit build.
14993PETSC_MAX_INT_32BIT = 2**31 - 1
14994#: Widest per-particle field (particle_field_catalog.c) and per-node grid field PICurv creates:
14995#: coordinates and velocities have 3 components, the statistics workspace 6 (grid.c, fda6).
14996PARTICLE_FIELD_MAX_COMPONENTS = 3
14997GRID_FIELD_MAX_COMPONENTS = 3
14998STATISTICS_FIELD_COMPONENTS = 6
14999
15000
15001def requested_petsc_index_bytes(args=None) -> "int | None":
15002 """!
15003 @brief The index width a command was told to use: `--petsc-index-bits`, else `PICURV_PETSC_INDEX_BITS`.
15004 @param[in] args Parsed command arguments, or None.
15005 @return 4, 8, or None when neither names one.
15006 """
15007 bits = getattr(args, "petsc_index_bits", None) if args is not None else None
15008 bits = bits or os.environ.get("PICURV_PETSC_INDEX_BITS") or None
15009 if bits is None:
15010 return None
15011 if str(bits).strip() not in PETSC_INDEX_BITS_CHOICES:
15012 raise ValueError(f"PETSc index bits must be 32 or 64, not {bits!r}.")
15013 return 4 if str(bits).strip() == "32" else 8
15014
15015
15016def required_petsc_index_bytes(case_cfg: dict, case_path: str, monitor_cfg: "dict | None",
15017 staged_grid: "str | None" = None) -> "tuple[int, str]":
15018 """!
15019 @brief The PetscInt width a new run needs, from its particle count and grid size.
15020 @details One PETSc object of more than PETSC_MAX_INT_32BIT entries needs 64-bit indices:
15021 a particle field (particles x 3) or a grid field (DA nodes x 3, or x 6 with field
15022 statistics). The solver checks the same limits when it creates them, so an
15023 underestimate here is refused at setup rather than run.
15024 @param[in] case_cfg Parsed case configuration.
15025 @param[in] case_path Path to case.yml.
15026 @param[in] monitor_cfg Parsed monitor configuration, for field statistics.
15027 @param[in] staged_grid Staged PICGRID, if any.
15028 @return (4 or 8, reason).
15029 """
15030 particles = (((case_cfg.get("models") or {}).get("physics") or {}).get("particles") or {})
15031 particle_entries = int(particles.get("count", 0) or 0) * PARTICLE_FIELD_MAX_COMPONENTS
15032 statistics = bool(monitor_cfg) and normalize_field_statistics_config(monitor_cfg, case_cfg)["enabled"]
15033 components = STATISTICS_FIELD_COMPONENTS if statistics else GRID_FIELD_MAX_COMPONENTS
15034 grid_entries = max([(im + 1) * (jm + 1) * (km + 1) * components
15035 for im, jm, km in resolve_grid_node_dims(case_cfg, case_path, staged_grid) or []] or [0])
15036 if particle_entries > PETSC_MAX_INT_32BIT:
15037 return 8, f"{particle_entries:,} particle-field entries exceed the 32-bit limit {PETSC_MAX_INT_32BIT:,}"
15038 if grid_entries > PETSC_MAX_INT_32BIT:
15039 return 8, f"{grid_entries:,} grid-field entries exceed the 32-bit limit {PETSC_MAX_INT_32BIT:,}"
15040 return 4, "particle and grid fields fit 32-bit indices"
15041
15042
15043def checkpoint_source_petsc_index_bytes(source_dir: str) -> "int | None":
15044 """!
15045 @brief PetscInt width the committed checkpoints under a run output root were written with.
15046 @details Every checkpoint of a run shares its build's width; one written before the width
15047 was recorded is 32-bit.
15048 @param[in] source_dir Run output root holding `checkpoints/`.
15049 @return 4 or 8, or None when it holds no committed checkpoint.
15050 """
15051 root = os.path.join(os.path.abspath(source_dir), "checkpoints")
15052 for name in sorted(os.listdir(root), reverse=True) if os.path.isdir(root) else []:
15053 metadata = os.path.join(root, name, "checkpoint.meta")
15054 if os.path.isfile(os.path.join(root, name, "COMMITTED")) and os.path.isfile(metadata):
15055 return int(_read_checkpoint_options(metadata).get("checkpoint_petsc_index_bytes", "4"))
15056 return None
15057
15058
15059def select_petsc_index_bytes(index_bytes: int, reason: str) -> int:
15060 """!
15061 @brief Make the current command drive the build of one PetscInt width.
15062 @details `resolve_runtime_executable()` and `runtime_petsc_index_bytes()` follow it, so the
15063 executables staged, pinned and launched, and every PETSc file the conductor writes
15064 or validates for them, agree. Refuses a width whose executables are not built.
15065 @param[in] index_bytes 4 or 8.
15066 @param[in] reason Why, for the message.
15067 @return index_bytes.
15068 """
15069 global _SELECTED_PETSC_INDEX_BYTES
15070 _SELECTED_PETSC_INDEX_BYTES = index_bytes
15071 if index_bytes == 8:
15072 missing = [path for path in (resolve_runtime_executable(name) for name in RUN_EXECUTABLE_NAMES)
15073 if not os.path.isfile(path)]
15074 if missing:
15075 raise ValueError(
15076 f"This run needs the 64-bit-index build ({reason}), which is not built: missing "
15077 f"{', '.join(missing)}. Build it with `make INDEX_BITS=64 PETSC_DIR=<64-bit-index PETSc> "
15078 f"[PETSC_ARCH=...] all`.")
15079 # stderr, so a dry-run's JSON plan on stdout stays parseable.
15080 print(f"[INFO] Using the 64-bit-index build: {reason}.", file=sys.stderr)
15081 return index_bytes
15082
15083
15084def select_run_petsc_index_bytes(args, case_cfg: dict, case_path: str, monitor_cfg: "dict | None",
15085 restart_source_dir: "str | None" = None) -> int:
15086 """!
15087 @brief Choose and select the PetscInt width a run's solve stage needs.
15088 @details A restart or continuation keeps the width its source checkpoint was written with;
15089 a new run uses 32-bit unless a particle or grid field outgrows it. An explicit
15090 `--petsc-index-bits` or `PICURV_PETSC_INDEX_BITS` wins, except against a checkpoint.
15091 @param[in] args Parsed command arguments.
15092 @param[in] case_cfg Parsed case configuration.
15093 @param[in] case_path Path to case.yml.
15094 @param[in] monitor_cfg Parsed monitor configuration.
15095 @param[in] restart_source_dir Restart source output root, if any.
15096 @return 4 or 8.
15097 """
15098 requested = requested_petsc_index_bytes(args)
15099 if restart_source_dir:
15100 recorded = checkpoint_source_petsc_index_bytes(restart_source_dir)
15101 if recorded is not None:
15102 if requested is not None and requested != recorded:
15103 raise ValueError(
15104 f"--petsc-index-bits {8 * requested} conflicts with the restart source {restart_source_dir}, "
15105 f"whose checkpoints were written with {8 * recorded}-bit indices.")
15106 return select_petsc_index_bytes(recorded, f"its restart source was written with {8 * recorded}-bit indices")
15107 needed, reason = required_petsc_index_bytes(case_cfg, case_path, monitor_cfg)
15108 if requested == 4 and needed == 8:
15109 raise ValueError(f"--petsc-index-bits 32 cannot run this case: {reason}.")
15110 if requested is not None:
15111 return select_petsc_index_bytes(requested, f"{8 * requested}-bit indices were requested")
15112 return select_petsc_index_bytes(needed, reason)
15113
15114
15115def select_existing_run_petsc_index_bytes(run_dir: str, args=None) -> int:
15116 """!
15117 @brief Select the PetscInt width an existing run was staged with, for its later stages.
15118 @details The width staging recorded in `config/active.json`, else its checkpoints' width,
15119 else 32-bit. A conflicting explicit request is refused.
15120 @param[in] run_dir Run directory.
15121 @param[in] args Parsed command arguments, or None.
15122 @return 4 or 8.
15123 """
15124 active = _read_json_if_exists(os.path.join(os.path.abspath(run_dir), "config", "active.json")) or {}
15125 recorded = active.get("petsc_index_bytes")
15126 if recorded is None:
15127 recorded = checkpoint_source_petsc_index_bytes(os.path.join(run_dir, CANONICAL_RUN_PATHS["output"]))
15128 requested = requested_petsc_index_bytes(args)
15129 if recorded is not None and requested is not None and requested != int(recorded):
15130 raise ValueError(f"--petsc-index-bits {8 * requested} conflicts with {run_dir}, staged with "
15131 f"{8 * int(recorded)}-bit indices.")
15132 width = int(recorded) if recorded is not None else (requested or 4)
15133 return select_petsc_index_bytes(width, f"{run_dir} was staged with {8 * width}-bit indices")
15134
15135
15136def record_run_petsc_index_bytes(run_dir: str) -> None:
15137 """!
15138 @brief Record in `config/active.json` the PetscInt width the current command selected.
15139 @param[in] run_dir Run directory.
15140 @return None.
15141 """
15142 active_path = os.path.join(os.path.abspath(run_dir), "config", "active.json")
15143 active = _read_json_if_exists(active_path)
15144 if isinstance(active, dict):
15145 active["petsc_index_bytes"] = _SELECTED_PETSC_INDEX_BYTES or 4
15146 write_json_file(active_path, active)
15147
15148
15149def payload_petsc_index_bytes(path: str) -> int:
15150 """!
15151 @brief PetscInt width of a PETSc payload: as its checkpoint recorded it, else the driven solver's.
15152 @details A committed checkpoint's `checkpoint.meta` records the width it was written with
15153 (`-checkpoint_petsc_index_bytes`; absent in checkpoints written before it existed,
15154 which are 32-bit). A payload outside any checkpoint is read as the driven solver
15155 would read it.
15156 @param[in] path Payload path.
15157 @return 4 or 8.
15158 """
15159 directory = os.path.dirname(os.path.abspath(path))
15160 for _ in range(4):
15161 metadata = os.path.join(directory, "checkpoint.meta")
15162 if os.path.isfile(metadata):
15163 return int(_read_checkpoint_options(metadata).get("checkpoint_petsc_index_bytes", "4"))
15164 directory = os.path.dirname(directory)
15166
15167
15168def petsc_index_arguments(index_bytes: int) -> list:
15169 """!
15170 @brief Generator arguments naming a PetscInt width; none for PETSc's default 32-bit.
15171 @details Kept empty at 4 bytes so a user's replacement generator script that predates the
15172 option keeps working for every 32-bit build.
15173 @param[in] index_bytes 4 or 8.
15174 @return Argument list.
15175 """
15176 return [] if index_bytes == 4 else ["--petsc-index-bytes", str(index_bytes)]
15177
15178
15179def validate_petsc_vec_binary(path: str, index_bytes: "int | None" = None) -> dict:
15180 """!
15181 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
15182 @param[in] path PETSc binary vector path.
15183 @param[in] index_bytes PetscInt width of the reading build; the driven solver's when None.
15184 @return Summary containing the absolute path, scalar count and index width.
15185 """
15186 import struct
15187 index_bytes = index_bytes or runtime_petsc_index_bytes()
15188 header_bytes = 2 * index_bytes
15189 with open(path, "rb") as fin:
15190 header = fin.read(header_bytes)
15191 if len(header) != header_bytes:
15192 raise ValueError(f"PETSc Vec file is too short: {path}")
15193 class_id, scalar_count = struct.unpack(">ii" if index_bytes == 4 else ">qq", header)
15194 payload = fin.read()
15195 if class_id != 1211214 or scalar_count < 0 or len(payload) != scalar_count * 8:
15196 raise ValueError(
15197 f"Invalid PETSc Vec file {path} for a PETSc with {index_bytes}-byte indices; a file "
15198 f"written for the other index width (or truncated) does not fit its header.")
15199 return {"path": os.path.abspath(path), "scalar_count": scalar_count, "index_bytes": index_bytes}
15200
15201
15202def write_scaled_petsc_vec_binary(source: str, destination: str, divisor: float) -> dict:
15203 """!
15204 @brief Copy a PETSc binary Vec, dividing every scalar by one factor.
15205 @details Used to stage a physical payload in solver units. The envelope is the one
15206 `validate_petsc_vec_binary()` accepts: a big-endian class id and count of the
15207 solver's index width, followed by big-endian doubles.
15208 @param[in] source Validated PETSc binary vector path.
15209 @param[in] destination Path to write the scaled vector to.
15210 @param[in] divisor Reference scale the payload is divided by.
15211 @return Summary of the destination, as `validate_petsc_vec_binary()` reports it.
15212 """
15213 from array import array
15214 index_bytes = validate_petsc_vec_binary(source)["index_bytes"]
15215 with open(source, "rb") as fin:
15216 header = fin.read(2 * index_bytes)
15217 values = array("d")
15218 values.frombytes(fin.read())
15219 if sys.byteorder == "little":
15220 values.byteswap()
15221 for index, value in enumerate(values):
15222 values[index] = value / divisor
15223 if sys.byteorder == "little":
15224 values.byteswap()
15225 with open(destination, "wb") as fout:
15226 fout.write(header)
15227 fout.write(values.tobytes())
15228 return validate_petsc_vec_binary(destination, index_bytes)
15229
15230def initial_condition_diagnostic_paths(run_dir, resolved_ic):
15231 """!
15232 @brief Resolve spectral IC artifacts through the canonical run layout, also for dry runs.
15233 @param[in] run_dir Run or asset-build root.
15234 @param[in] resolved_ic Normalized file-backed IC provider.
15235 @return Canonical diagnostic artifact paths, including each sample CSV and JSON.
15236 """
15237 if resolved_ic["kind"] not in SPECTRAL_IC_PROVIDERS:
15238 return []
15239 paths = [os.path.join(run_dir, CANONICAL_RUN_PATHS["metrics"], "initial_condition_summary.json")]
15240 if resolved_ic["kind"] in SHELL_SPECTRUM_IC_PROVIDERS:
15241 return paths + [os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)]
15242 for task in resolved_ic["params"].get("initial_spectra", []):
15243 base = post_spectra_task_basename(task, "initial_condition")
15244 paths.extend([os.path.join(run_dir, CANONICAL_RUN_PATHS["spectra"], base+suffix) for suffix in (".csv", ".json")])
15245 return paths
15246
15247
15248def run_initial_spectrum_generator(field_path: str, staged_grid: str,
15249 spectrum_path: str, case_dir: str, task_cfg=None) -> str:
15250 """!
15251 @brief Measure the shell-averaged spectrum of a staged initial condition.
15252
15253 The spectrum has a single implementation in `generators/spectra.gen`, so the
15254 conductor measures the generated field rather than asking the initial-condition
15255 generator to report a spectrum it would have to bin itself.
15256
15257 @param[in] field_path Generated PETSc binary Ucat path.
15258 @param[in] staged_grid Staged canonical PICGRID path.
15259 @param[in] spectrum_path Destination `k,energy` CSV path.
15260 @param[in] case_dir Working directory for the subprocess.
15261 @param[in] task_cfg Optional normalized plane/line task; None retains DIT shell measurement.
15262 @return Absolute path to the written spectrum CSV.
15263 @throws ValueError when the generator is missing or fails.
15264 """
15265 script = os.path.join(GENERATORS_PATH, "spectra.gen")
15266 if not os.path.isfile(script):
15267 raise ValueError(f"spectra.gen script not found: {script}")
15268 command = task_cfg["task"].replace("_", "-") if task_cfg else "shell-spectrum"
15269 cmd = [sys.executable, script, command,
15270 "--field-file", field_path, "--source-grid", staged_grid,
15271 "--spectrum-csv", spectrum_path, *petsc_index_arguments(runtime_petsc_index_bytes())]
15272 if task_cfg:
15273 cmd.extend(["--axes", *task_cfg["axes"], "--fixed-indices", json.dumps(task_cfg["fixed_indices"]),
15274 "--subtract-mean", task_cfg["subtract_mean"], "--summary-json", os.path.splitext(spectrum_path)[0]+".json"])
15275 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
15276 if result.returncode != 0:
15277 details = (result.stderr or result.stdout or "").strip()
15278 raise ValueError(
15279 f"initial-condition spectrum failed with exit code {result.returncode}. Details:\n{details}"
15280 )
15281 return os.path.abspath(spectrum_path)
15282
15283
15284def run_initial_condition_generator(case_path: str, run_dir: str, resolved_ic: dict) -> str:
15285 """!
15286 @brief Run the repository IC generator.
15287 @param[in] case_path Source case YAML path.
15288 @param[in] run_dir Run or precompute output directory.
15289 @param[in] resolved_ic Normalized external-generator contract.
15290 @return Generated PETSc vector path.
15291 """
15292 case_dir = os.path.dirname(os.path.abspath(case_path))
15293 if resolved_ic["kind"] in SPECTRAL_IC_PROVIDERS:
15294 script = os.path.join(GENERATORS_PATH, "ic.gen")
15295 output_path = os.path.join(run_dir, "inputs", "initial_condition", "initial_condition.generated.dat")
15296 os.makedirs(os.path.dirname(output_path), exist_ok=True)
15297 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
15298 if not os.path.isfile(staged_grid):
15299 raise ValueError(f"{resolved_ic['kind']} requires a staged PICGRID at inputs/grid/grid.run.")
15300 summary_path = os.path.join(run_dir, "output", "analysis", "metrics", "initial_condition_summary.json")
15301 spectrum_path = os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)
15302 os.makedirs(os.path.dirname(summary_path), exist_ok=True)
15303 os.makedirs(os.path.dirname(spectrum_path), exist_ok=True)
15304 provider_params = resolved_ic["params"]
15305 provider_context = dict(resolved_ic.get("provider_context", {}))
15306 measured_field = output_path
15307 if resolved_ic["kind"] == "resampled_velocity" and provider_params.get("field") == "Ucont":
15308 # Staged fluxes are not a velocity; the spectrum is measured on the velocity the
15309 # runtime reconstructs from them, written beside the output and removed after.
15310 measured_field = os.path.join(os.path.dirname(output_path), ".runtime_ucat_for_spectrum.dat")
15311 provider_context["runtime_ucat_output"] = measured_field
15312 if resolved_ic["kind"] == "resampled_velocity":
15313 # The source is a workspace input, resolved here so the provider is handed one
15314 # absolute path; a reference-mode import resolves to its external target.
15315 provider_params = dict(provider_params)
15316 provider_params["source_file"] = resolve_workspace_path(case_path, provider_params["source_file"])
15317 if not os.path.isfile(provider_params["source_file"]):
15318 raise ValueError(f"resampled_velocity source file not found: {provider_params['source_file']}")
15319 cmd = [sys.executable, script, "--generator", resolved_ic["kind"],
15320 "--grid", staged_grid, "--output", output_path,
15321 "--params-json", json.dumps(provider_params, sort_keys=True),
15322 "--context-json", json.dumps(provider_context, sort_keys=True),
15323 "--summary-json", summary_path, *petsc_index_arguments(runtime_petsc_index_bytes())]
15324 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
15325 if result.returncode != 0:
15326 details = (result.stderr or result.stdout or "").strip()
15327 raise ValueError(f"{resolved_ic['kind']} failed with exit code {result.returncode}. Details:\n{details}")
15328 validate_petsc_vec_binary(output_path)
15329 if resolved_ic["kind"] in SHELL_SPECTRUM_IC_PROVIDERS:
15330 try:
15331 run_initial_spectrum_generator(measured_field, staged_grid, spectrum_path, case_dir)
15332 finally:
15333 if measured_field != output_path and os.path.exists(measured_field):
15334 os.remove(measured_field)
15335 else:
15336 paths = initial_condition_diagnostic_paths(run_dir, resolved_ic)
15337 for task, path in zip(resolved_ic["params"].get("initial_spectra", []), paths[1::2]):
15338 run_initial_spectrum_generator(output_path, staged_grid, path, case_dir, task)
15339 return output_path
15340 script = _resolve_generator_script(resolved_ic.get("script"), case_path, "ic.gen")
15341 config_file = resolved_ic["config_file"]
15342 config_file = _resolve_case_relative_path(config_file, case_dir)
15343 if not os.path.isfile(script):
15344 raise ValueError(f"ic.gen script not found: {script}")
15345 if not os.path.isfile(config_file):
15346 raise ValueError(f"initial-condition generator config file not found: {config_file}")
15347 output_path = os.path.join(run_dir, "inputs", "initial_condition", "initial_condition.generated.dat")
15348 os.makedirs(os.path.dirname(output_path), exist_ok=True)
15349 cmd = [sys.executable, script, "-c", config_file, "--field",
15350 "Ucat" if resolved_ic["field_code"] == 0 else "Ucont",
15351 "--output", output_path, *petsc_index_arguments(runtime_petsc_index_bytes())]
15352 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
15353 if os.path.isfile(staged_grid):
15354 cmd.extend(["--grid", staged_grid])
15355 # Expressions are written in physical units; the generator is given the scales and
15356 # writes solver units, like every other initial-condition provider.
15357 cmd.extend(["--length-ref", repr(float(resolved_ic["scales"]["length_ref"])),
15358 "--velocity-ref", repr(float(resolved_ic["scales"]["velocity_ref"]))])
15359 cmd.extend(str(token) for token in resolved_ic.get("cli_args", []))
15360 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
15361 if result.returncode != 0:
15362 details = (result.stderr or result.stdout or "").strip()
15363 raise ValueError(f"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
15364 validate_petsc_vec_binary(output_path)
15365 return output_path
15366
15367def _check_file_ic_matches_grid(payload: dict, staged_grid: str, resolved_ic: dict) -> None:
15368 """!
15369 @brief Refuse a `mode: file` payload sized for another grid, and say what imports one.
15370 @details `mode: file` reads its vector verbatim, so it must already be laid out for this
15371 grid: one value per component per cell including one dummy layer. The runtime
15372 would refuse a mismatch at startup; staging refuses it first and names
15373 `resampled_velocity`, which brings a field from another grid onto this one.
15374 Programmatic grids are not staged here, so they keep the runtime check.
15375 @param[in] payload Header summary from `validate_petsc_vec_binary()`.
15376 @param[in] staged_grid Staged PICGRID path, if the grid is file-backed.
15377 @param[in] resolved_ic Normalized file-backed IC contract.
15378 """
15379 if not os.path.isfile(staged_grid):
15380 return
15381 dims = read_picgrid_header_dimensions(staged_grid)
15382 if len(dims) != 1:
15383 return
15384 im, jm, km = dims[0]
15385 expected = (im + 1) * (jm + 1) * (km + 1) * 3
15386 if payload["scalar_count"] != expected:
15387 raise ValueError(
15388 f"Initial-condition file {payload['path']} holds {payload['scalar_count']} values; this grid "
15389 f"({im - 1}x{jm - 1}x{km - 1} cells) needs {expected} for {resolved_ic.get('field_name', 'the field')}. "
15390 "mode: file reads a field already laid out for this grid. To bring in a field from another "
15391 "grid or format, use generator: resampled_velocity."
15392 )
15393
15394
15395def stage_initial_condition_file(run_dir: str, case_path: str, resolved_ic: dict) -> dict:
15396 """!
15397 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
15398 @param[in] run_dir Run or precompute output directory.
15399 @param[in] case_path Source case YAML path.
15400 @param[in] resolved_ic Normalized file-backed IC contract.
15401 @return Source, staged path, and staging-directory summary.
15402 """
15403 stage_dir = os.path.join(run_dir, "inputs", "initial_condition")
15404 os.makedirs(stage_dir, exist_ok=True)
15405 staged_path = os.path.join(stage_dir, f"{resolved_ic['field_name']}00000_0.dat")
15406 if os.path.isfile(staged_path):
15407 validate_petsc_vec_binary(staged_path)
15408 summary = {
15409 "source": os.path.abspath(staged_path),
15410 "staged": os.path.abspath(staged_path),
15411 "directory": os.path.abspath(stage_dir),
15412 "reused": True,
15413 }
15414 if resolved_ic["kind"] in SPECTRAL_IC_PROVIDERS:
15415 summary["diagnostics"] = initial_condition_diagnostic_paths(run_dir, resolved_ic)
15416 return summary
15417 divisor = 1.0
15418 if is_generated_ic_provider(resolved_ic):
15419 # Providers are handed the scales they need and write solver units.
15420 source_path = run_initial_condition_generator(case_path, run_dir, resolved_ic)
15421 else:
15422 case_dir = os.path.dirname(os.path.abspath(case_path))
15423 source_path = _resolve_case_relative_path(resolved_ic["source_file"], case_dir)
15424 if not os.path.isfile(source_path):
15425 raise ValueError(f"Initial-condition source file not found: {source_path}")
15426 payload = validate_petsc_vec_binary(source_path)
15427 _check_file_ic_matches_grid(payload, os.path.join(run_dir, "inputs", "grid", "grid.run"), resolved_ic)
15428 divisor = ic_payload_divisor(resolved_ic, case_dir)
15429 if divisor != 1.0:
15430 write_scaled_petsc_vec_binary(source_path, staged_path, divisor)
15431 elif os.path.abspath(source_path) != os.path.abspath(staged_path):
15432 shutil.copy2(source_path, staged_path)
15433 summary = {"source": os.path.abspath(source_path), "staged": os.path.abspath(staged_path),
15434 "directory": os.path.abspath(stage_dir), "payload_divisor": divisor}
15435 if resolved_ic["kind"] in SPECTRAL_IC_PROVIDERS:
15436 summary["diagnostics"] = initial_condition_diagnostic_paths(run_dir, resolved_ic)
15437 return summary
15438
15439def normalize_flow_direction_token(value: str) -> int:
15440 """!
15441 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
15442 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
15443 @return Integer 0-5 matching the FlowDirection enum.
15444 @throws ValueError on unknown value.
15445 """
15446 mapped = {
15447 "+Xi": 0, "-Xi": 1,
15448 "+Eta": 2, "-Eta": 3,
15449 "+Zeta": 4, "-Zeta": 5,
15450 }.get(str(value).strip())
15451 if mapped is None:
15452 raise ValueError(
15453 f"Unknown initial_conditions.flow_direction '{value}'. "
15454 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
15455 )
15456 return mapped
15457
15458def _ic_has_inlet(prepared_blocks) -> bool:
15459 """!
15460 @brief Return True if any prepared BC block contains an INLET face.
15461 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
15462 @return True if at least one INLET entry exists across all blocks.
15463 """
15464 if not prepared_blocks:
15465 return False
15466 for block_bcs in prepared_blocks:
15467 for entry in block_bcs:
15468 if entry.get("type") == "INLET":
15469 return True
15470 return False
15471
15472def resolve_ic_cli_params(ic: dict, finit_code: int, prepared_blocks, scales: dict) -> dict:
15473 """!
15474 @brief Resolve all IC parameters and return a dict of PETSc option values.
15475 @param[in] ic The properties.initial_conditions mapping.
15476 @param[in] finit_code Normalized -finit integer code.
15477 @param[in] prepared_blocks Normalized BC blocks (may be None).
15478 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
15479 @return Dict with keys matching PETSc option names (without leading dash).
15480 @throws KeyError if a required key is absent.
15481 @throws ValueError on invalid combinations or values.
15482 """
15483 result = {}
15484
15485 if finit_code == 0:
15486 return result
15487
15488 if finit_code == 1: # Constant
15489 has_cartesian = any(k in ic for k in ("u_physical", "v_physical", "w_physical"))
15490 has_curvilinear = "velocity_physical" in ic
15491
15492 if has_cartesian and has_curvilinear:
15493 raise ValueError(
15494 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
15495 "velocity_physical (curvilinear) — use one or the other."
15496 )
15497
15498 if has_curvilinear: # curvilinear: scalar speed along flow_direction axis
15499 cs_code = 1
15500 result["ic_coordinate_system"] = cs_code
15501 try:
15502 vel_phys = float(ic["velocity_physical"])
15503 except (TypeError, ValueError) as exc:
15504 raise ValueError(
15505 f"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
15506 "Expected a numeric value."
15507 ) from exc
15508 result["ic_velocity_physical"] = to_solver_units(
15509 vel_phys, ("case", "properties", "initial_conditions", "velocity_physical"), scales)
15510
15511 if "flow_direction" in ic:
15512 result["flow_direction"] = normalize_flow_direction_token(ic["flow_direction"])
15513 elif not _ic_has_inlet(prepared_blocks):
15514 raise ValueError(
15515 "initial_conditions.flow_direction is required for curvilinear Constant IC "
15516 "when no INLET face exists."
15517 )
15518
15519 else: # cartesian: u/v/w_physical → Cart2Contra (default when no velocity_physical)
15520 if "flow_direction" in ic:
15521 raise ValueError(
15522 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
15523 "Use velocity_physical + flow_direction for curvilinear mode."
15524 )
15525 cs_code = 0
15526 result["ic_coordinate_system"] = cs_code
15527 u, v, w = parse_initial_velocity_components(ic, finit_code, require_explicit=True)
15528 ic_path = ("case", "properties", "initial_conditions")
15529 result["ucont_x"] = to_solver_units(u, ic_path + ("u_physical",), scales)
15530 result["ucont_y"] = to_solver_units(v, ic_path + ("v_physical",), scales)
15531 result["ucont_z"] = to_solver_units(w, ic_path + ("w_physical",), scales)
15532
15533 elif finit_code == 2: # Poiseuille
15534 if any(k in ic for k in ("u_physical", "v_physical", "w_physical")):
15535 raise ValueError(
15536 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
15537 )
15538 if "velocity_physical" in ic:
15539 raise ValueError(
15540 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
15541 )
15542 if "peak_velocity_physical" not in ic:
15543 raise KeyError("peak_velocity_physical")
15544 try:
15545 peak = float(ic["peak_velocity_physical"])
15546 except (TypeError, ValueError) as exc:
15547 raise ValueError(
15548 f"Invalid value for initial_conditions.peak_velocity_physical: "
15549 f"{ic['peak_velocity_physical']!r}. Expected a numeric value."
15550 ) from exc
15551 result["ic_velocity_physical"] = to_solver_units(
15552 peak, ("case", "properties", "initial_conditions", "peak_velocity_physical"), scales)
15553
15554 if "flow_direction" in ic:
15555 fd_int = normalize_flow_direction_token(ic["flow_direction"])
15556 # Cross-check: explicit flow_direction must align with INLET face if one exists
15557 if _ic_has_inlet(prepared_blocks):
15558 inlet_axis = infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks)
15559 fd_axis_name = {0: "x", 1: "y", 2: "z"}.get(fd_int // 2, "?")
15560 if inlet_axis and fd_axis_name != inlet_axis:
15561 token = ic["flow_direction"]
15562 raise ValueError(
15563 f"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
15564 f"does not match INLET face axis '{inlet_axis}'."
15565 )
15566 result["flow_direction"] = fd_int
15567 elif not _ic_has_inlet(prepared_blocks):
15568 raise ValueError(
15569 "initial_conditions.flow_direction is required for Poiseuille IC "
15570 "when no INLET face exists."
15571 )
15572
15573 return result
15574
15575def normalize_eulerian_field_source(value: str) -> str:
15576 """!
15577 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
15578 @param[in] value Human-readable or enum-like Eulerian field source.
15579 @return Canonical string accepted by `-euler_field_source`.
15580 @throws ValueError if the input cannot be mapped.
15581 """
15582 if value is None:
15583 raise ValueError("eulerian_field_source cannot be None")
15584
15585 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15586 aliases = {
15587 "solve": "solve",
15588 "load": "load",
15589 "analytical": "analytical",
15590 }
15591 mapped = aliases.get(normalized)
15592 if mapped is None:
15593 raise ValueError(
15594 f"Unknown operation_mode.eulerian_field_source '{value}'. "
15595 "Use one of: 'solve', 'load', 'analytical'."
15596 )
15597 return mapped
15598
15599
15600def initial_condition_is_authoritative(case_cfg: dict, solver_cfg: dict) -> bool:
15601 """!
15602 @brief Whether the configured initial condition is the field the solver starts from.
15603 @details A restart (start_step > 0) starts from saved state, and the load and
15604 analytical Eulerian sources supply their own field, so in each of those the
15605 configured initial condition is never read.
15606 @param[in] case_cfg Parsed case configuration.
15607 @param[in] solver_cfg Parsed solver configuration.
15608 @return True only for a fresh solve.
15609 """
15611 ((solver_cfg or {}).get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
15612 )
15613 start_step = int(((case_cfg or {}).get("run_control", {}) or {}).get("start_step", 0) or 0)
15614 return source == "solve" and start_step == 0
15615
15616
15617def unused_asset_kinds(case_cfg: dict, solver_cfg: dict) -> tuple:
15618 """!
15619 @brief Asset kinds a run with these configurations would build but never read.
15620 @param[in] case_cfg Parsed case configuration.
15621 @param[in] solver_cfg Parsed solver configuration.
15622 @return Kinds to leave out of the run's asset materialization.
15623 """
15624 return () if initial_condition_is_authoritative(case_cfg, solver_cfg) else ("initial-condition",)
15625
15626
15627def normalize_analytical_type(value: str) -> str:
15628 """!
15629 @brief Normalizes the analytical solution selector to the C-side canonical string.
15630 @param[in] value Human-readable analytical solution selector.
15631 @return Canonical string accepted by `-analytical_type`.
15632 @throws ValueError if the input cannot be mapped.
15633 """
15634 # Canonical selector map for analytical solution selectors.
15635 if value is None:
15636 raise ValueError("analytical_type cannot be None")
15637
15638 normalized = str(value).strip().upper().replace("-", "_").replace(" ", "_")
15639 if normalized not in ANALYTICAL_SOLUTION_TYPES:
15640 raise ValueError(
15641 f"Unknown operation_mode.analytical_type '{value}'. "
15642 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
15643 )
15644 return normalized
15645
15646def parse_initial_velocity_components(initial_conditions: dict, finit_code: int, *, require_explicit: bool) -> "tuple[float, float, float]":
15647 """!
15648 @brief Parse initial-condition velocity components with mode-aware defaults.
15649 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
15650 @param[in] finit_code Normalized `-finit` integer code.
15651 @param[in] require_explicit If True, all three component keys must be present.
15652 @return Tuple `(u, v, w)` in physical units.
15653 @throws KeyError if a required component key is missing.
15654 @throws ValueError if a component cannot be converted to float.
15655 """
15656 component_keys = ("u_physical", "v_physical", "w_physical")
15657 components = []
15658 for key in component_keys:
15659 if key not in initial_conditions:
15660 if require_explicit:
15661 raise KeyError(key)
15662 raw_value = 0.0
15663 else:
15664 raw_value = initial_conditions[key]
15665 try:
15666 components.append(float(raw_value))
15667 except (TypeError, ValueError) as exc:
15668 raise ValueError(
15669 f"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
15670 ) from exc
15671 return tuple(components)
15672
15673def infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks: list) -> "str | None":
15674 """!
15675 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
15676 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
15677 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
15678 @throws ValueError if different blocks imply different inlet axes.
15679 """
15680 face_order = ("-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta")
15681 face_axis = {
15682 "-Xi": "x", "+Xi": "x",
15683 "-Eta": "y", "+Eta": "y",
15684 "-Zeta": "z", "+Zeta": "z",
15685 }
15686
15687 inlet_axes = set()
15688 for block_bcs in prepared_blocks:
15689 face_map = {entry["face"]: entry for entry in block_bcs}
15690 for face in face_order:
15691 entry = face_map.get(face)
15692 if entry and entry["type"] == "INLET":
15693 inlet_axes.add(face_axis[face])
15694 break
15695
15696 if not inlet_axes:
15697 return None
15698 if len(inlet_axes) != 1:
15699 raise ValueError(
15700 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
15701 f"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
15702 )
15703 return next(iter(inlet_axes))
15704
15705def normalize_particle_init_mode(value: str) -> int:
15706 """!
15707 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
15708 @param[in] value Canonical particle initialization mode.
15709 @return Canonical integer code accepted by -pinit.
15710 @throws ValueError if the input cannot be mapped.
15711 """
15712 # Canonical selector map for particle initialization modes.
15713 if value is None:
15714 raise ValueError("particle init mode cannot be None")
15715
15716 name = str(value).strip()
15717 # SurfaceEdges was a diagnostic pattern whose placement depended on the rank layout.
15718 if name == "SurfaceEdges":
15719 raise ValueError(
15720 "particle init_mode 'SurfaceEdges' is retired: use 'Surface' for inlet seeding, "
15721 "or 'PointSource' to place particles at a chosen point."
15722 )
15723 mapped = {
15724 "Surface": 0,
15725 "Volume": 1,
15726 "PointSource": 2,
15727 }.get(name)
15728 if mapped is None:
15729 raise ValueError(
15730 f"Unknown particle init_mode '{value}'. Use one of: "
15731 "'Surface', 'Volume', 'PointSource'."
15732 )
15733 return mapped
15734
15735def normalize_interpolation_method(value: str) -> int:
15736 """!
15737 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
15738 @param[in] value Canonical interpolation method name.
15739 @return Integer code accepted by -interpolation_method.
15740 @throws ValueError if the input cannot be mapped.
15741 """
15742 if value is None:
15743 raise ValueError("interpolation method cannot be None")
15744
15745 mapped = {
15746 "Trilinear": 0,
15747 "CornerAveraged": 1,
15748 "FaceFlux": 2,
15749 }.get(str(value).strip())
15750 if mapped is None:
15751 raise ValueError(
15752 f"Unknown interpolation_method '{value}'. Use one of: "
15753 "'Trilinear', 'CornerAveraged', 'FaceFlux'."
15754 )
15755 return mapped
15756
15757#: Canonical spelling of each LES model code, for diagnostics.
15758LES_MODEL_NAMES = {1: "constant_smagorinsky", 2: "dynamic_smagorinsky", 3: "vreman", 4: "wale"}
15759
15760#: Parameters that configure exactly one LES model and are refused under the others.
15761LES_MODEL_OWNED_KEYS = {
15762 1: ("constant_cs",),
15763 2: ("dynamic_frequency", "test_filter", "averaging", "clipping"),
15764 3: ("vreman_coefficient",),
15765 4: ("wale_coefficient",),
15766}
15767
15768#: How a model's own parameters are described when they are refused elsewhere.
15769LES_MODEL_KEY_OWNER_DESCRIPTIONS = {
15770 1: "the constant Smagorinsky coefficient",
15771 2: "the dynamic procedure",
15772 3: "the Vreman model",
15773 4: "the WALE model",
15774}
15775
15776
15777def normalize_les_model(value) -> int:
15778 """!
15779 @brief Maps LES model selectors to C enum/int codes (-les).
15780 @param[in] value LES selector name or legacy integer/bool value.
15781 @return Integer code accepted by -les.
15782 @throws ValueError if the input cannot be mapped.
15783 """
15784 if isinstance(value, bool):
15785 return 1 if value else 0
15786 if isinstance(value, int):
15787 if value in (0, 1, 2, 3, 4):
15788 return value
15789 raise ValueError("models.physics.turbulence.les must be 0-4, false/true, or a supported model block.")
15790 if value is None:
15791 raise ValueError("LES model cannot be None")
15792
15793 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15794 mapped = {
15795 "none": 0,
15796 "off": 0,
15797 "disabled": 0,
15798 "no_les": 0,
15799 "constant": 1,
15800 "constant_smagorinsky": 1,
15801 "smagorinsky": 1,
15802 "dynamic": 2,
15803 "dynamic_smagorinsky": 2,
15804 "vreman": 3,
15805 "wale": 4,
15806 }.get(key)
15807 if mapped is None:
15808 raise ValueError(
15809 f"Unknown LES model '{value}'. Use one of: 'none', "
15810 "'constant_smagorinsky', 'dynamic_smagorinsky', 'vreman', 'wale'."
15811 )
15812 return mapped
15813
15815 """!
15816 @brief Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
15817 @param[in] value Test-filter selector name or integer code.
15818 @return 0 for the volume-weighted box filter, 1 for the i/k Simpson filter.
15819 @throws ValueError if the input cannot be mapped.
15820 """
15821 if isinstance(value, bool):
15822 raise ValueError("models.physics.turbulence.les.test_filter.kernel must name a filter, not a boolean.")
15823 if isinstance(value, int):
15824 if value in (0, 1):
15825 return value
15826 raise ValueError("models.physics.turbulence.les.test_filter.kernel must be 0, 1, or a supported filter name.")
15827 if value is None:
15828 raise ValueError("LES test_filter.kernel cannot be None")
15829
15830 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15831 mapped = {
15832 "volume_weighted_box": 0,
15833 "box": 0,
15834 "simpson_ik": 1,
15835 }.get(key)
15836 if mapped is None:
15837 raise ValueError(
15838 f"Unknown LES test filter kernel '{value}'. Use one of: "
15839 "'volume_weighted_box', 'simpson_ik'."
15840 )
15841 return mapped
15842
15844 """!
15845 @brief Maps LES grid-filter-width model names to the C -les_filter_width flag.
15846 @param[in] value Filter-width model name or integer code.
15847 @return 0 for cube-root volume, 1 for the geometric mean of the cell extents,
15848 2 for the longest cell extent, 3 for the Scotti aspect-ratio correction.
15849 @throws ValueError if the input cannot be mapped.
15850 """
15851 if isinstance(value, bool):
15852 raise ValueError("models.physics.turbulence.les.filter_width must name a model, not a boolean.")
15853 if isinstance(value, int):
15854 if value in (0, 1, 2, 3):
15855 return value
15856 raise ValueError("models.physics.turbulence.les.filter_width must be 0, 1, 2, 3, or a supported model name.")
15857 if value is None:
15858 raise ValueError("LES filter_width cannot be None")
15859
15860 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15861 mapped = {
15862 "cube_root_volume": 0,
15863 "geometric_mean": 1,
15864 "max_edge": 2,
15865 "scotti": 3,
15866 }.get(key)
15867 if mapped is None:
15868 raise ValueError(
15869 f"Unknown LES filter width model '{value}'. Use one of: "
15870 "'cube_root_volume', 'geometric_mean', 'max_edge', 'scotti'."
15871 )
15872 return mapped
15873
15875 """!
15876 @brief Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
15877 @param[in] value Averaging mode name or integer code.
15878 @return 0 for pointwise local averaging, 1 for homogeneous directions, 2 for the
15879 whole block.
15880 @throws ValueError if the input cannot be mapped.
15881 """
15882 if isinstance(value, bool):
15883 raise ValueError("models.physics.turbulence.les.averaging.mode must name a mode, not a boolean.")
15884 if isinstance(value, int):
15885 if value in (0, 1, 2):
15886 return value
15887 raise ValueError("models.physics.turbulence.les.averaging.mode must be 0, 1, 2, or a supported mode name.")
15888 if value is None:
15889 raise ValueError("LES averaging.mode cannot be None")
15890
15891 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15892 mapped = {
15893 "local": 0,
15894 "homogeneous": 1,
15895 "global": 2,
15896 }.get(key)
15897 if mapped is None:
15898 raise ValueError(
15899 f"Unknown LES averaging mode '{value}'. Use one of: "
15900 "'local', 'homogeneous', 'global'."
15901 )
15902 return mapped
15903
15904def normalize_les_clip_mode(value) -> int:
15905 """!
15906 @brief Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
15907 @param[in] value Clipping mode name or integer code.
15908 @return 0 to clamp into [0, max_cs^2], 1 to discard negatives only, 2 to keep the
15909 signed coefficient so backscatter survives.
15910 @throws ValueError if the input cannot be mapped.
15911 """
15912 if isinstance(value, bool):
15913 raise ValueError("models.physics.turbulence.les.clipping.mode must name a mode, not a boolean.")
15914 if isinstance(value, int):
15915 if value in (0, 1, 2):
15916 return value
15917 raise ValueError("models.physics.turbulence.les.clipping.mode must be 0, 1, 2, or a supported mode name.")
15918 if value is None:
15919 raise ValueError("LES clipping.mode cannot be None")
15920
15921 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15922 mapped = {
15923 "clamp": 0,
15924 "clip_negative": 1,
15925 "none": 2,
15926 }.get(key)
15927 if mapped is None:
15928 raise ValueError(
15929 f"Unknown LES clipping mode '{value}'. Use one of: "
15930 "'clamp', 'clip_negative', 'none'."
15931 )
15932 return mapped
15933
15934#: The logical grid directions an LES averaging set may span, in canonical order.
15935LES_AVERAGING_DIRECTION_AXES = ("i", "j", "k")
15936
15938 """!
15939 @brief Maps a list of homogeneous logical directions to the C flag's string form.
15940 @param[in] value List or string naming a subset of the i, j, and k directions.
15941 @return The selected directions as a canonically ordered subset of "ijk".
15942 @throws ValueError if a direction is unknown or repeated.
15943 """
15944 if value is None:
15945 return ""
15946 if isinstance(value, str):
15947 tokens = [token for token in value.strip().lower()]
15948 elif isinstance(value, (list, tuple)):
15949 tokens = [str(token).strip().lower() for token in value]
15950 else:
15951 raise ValueError(
15952 "models.physics.turbulence.les.averaging.directions must be a list such as [i, k]."
15953 )
15954
15955 selected = []
15956 for token in tokens:
15957 if token not in LES_AVERAGING_DIRECTION_AXES:
15958 raise ValueError(
15959 f"Unknown LES averaging direction '{token}'. Use a subset of ['i', 'j', 'k']."
15960 )
15961 if token in selected:
15962 raise ValueError(
15963 f"LES averaging direction '{token}' is repeated; list each direction once."
15964 )
15965 selected.append(token)
15966 return "".join(axis for axis in LES_AVERAGING_DIRECTION_AXES if axis in selected)
15967
15969 """!
15970 @brief Maps wall-function model selectors to the C -wallfunction flag.
15971 @param[in] value Wall-function selector name, or None for the default.
15972 @return Integer code accepted by -wallfunction; 1 log law, 2 Werner-Wengle, 3 Cabot.
15973 @throws ValueError if the input cannot be mapped.
15974 """
15975 if value is None:
15976 return 1
15977 if isinstance(value, bool):
15978 raise ValueError("models.physics.turbulence.wall_function.model must name a model, not a boolean.")
15979 if isinstance(value, int):
15980 if value in (1, 2, 3):
15981 return value
15982 raise ValueError("models.physics.turbulence.wall_function.model must be 1, 2, 3, or a supported model name.")
15983 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15984 mapped = {
15985 "log_law": 1,
15986 "loglaw": 1,
15987 "werner": 2,
15988 "werner_wengle": 2,
15989 "cabot": 3,
15990 }.get(key)
15991 if mapped is None:
15992 raise ValueError(
15993 "Unknown wall_function model '%s'. Use one of: 'log_law', 'werner', 'cabot'." % value)
15994 return mapped
15995
15996def resolve_enabled_flag(cfg: dict, path: str, default: bool = True) -> bool:
15997 """!
15998 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
15999 @param[in] cfg Mapping that may contain `enabled`.
16000 @param[in] path Human-readable config path for diagnostics.
16001 @param[in] default Value used when `enabled` is omitted.
16002 @return Boolean enabled state.
16003 @throws ValueError if `enabled` is not a YAML boolean.
16004 """
16005 if 'enabled' not in cfg:
16006 return default
16007 if not isinstance(cfg['enabled'], bool):
16008 raise ValueError(f"{path}.enabled must be true or false.")
16009 return cfg['enabled']
16010
16011def append_les_parameter_flags(les_cfg: dict, control_lines: list):
16012 """!
16013 @brief Appends the LES closure parameter flags from a structured les block.
16014 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
16015 @param[out] control_lines A list of strings to which C-flags will be appended.
16016 @throws ValueError if a selector name or nested block shape is unsupported.
16017 """
16018 if 'constant_cs' in les_cfg:
16019 control_lines.append(f"-les_constant_cs {format_flag_value(les_cfg['constant_cs'])}")
16020 if 'vreman_coefficient' in les_cfg:
16021 control_lines.append(f"-les_vreman_coefficient {format_flag_value(les_cfg['vreman_coefficient'])}")
16022 if 'wale_coefficient' in les_cfg:
16023 control_lines.append(f"-les_wale_coefficient {format_flag_value(les_cfg['wale_coefficient'])}")
16024 if 'dynamic_frequency' in les_cfg:
16025 control_lines.append(f"-les_dynamic_frequency {format_flag_value(les_cfg['dynamic_frequency'])}")
16026 if 'filter_width' in les_cfg:
16027 control_lines.append(f"-les_filter_width {normalize_les_filter_width(les_cfg['filter_width'])}")
16028
16029 # A bare string is accepted as shorthand for naming only the kernel, matching the
16030 # way the les block itself accepts either a scalar or a mapping.
16031 test_filter = les_cfg.get('test_filter')
16032 if test_filter is not None:
16033 if not isinstance(test_filter, dict):
16034 test_filter = {'kernel': test_filter}
16035 if 'kernel' in test_filter:
16036 control_lines.append(
16037 f"-les_test_filter_kernel {normalize_les_test_filter(test_filter['kernel'])}")
16038 if 'width_ratio' in test_filter:
16039 control_lines.append(
16040 f"-les_test_filter_width_ratio {format_flag_value(test_filter['width_ratio'])}")
16041
16042 averaging = les_cfg.get('averaging')
16043 if averaging is not None:
16044 if not isinstance(averaging, dict):
16045 averaging = {'mode': averaging}
16046 if 'mode' in averaging:
16047 control_lines.append(
16048 f"-les_averaging_mode {normalize_les_averaging_mode(averaging['mode'])}")
16049 if 'directions' in averaging:
16050 directions = normalize_les_averaging_directions(averaging['directions'])
16051 if directions:
16052 control_lines.append(f"-les_averaging_directions {directions}")
16053
16054 clipping = les_cfg.get('clipping')
16055 if clipping is not None:
16056 if not isinstance(clipping, dict):
16057 clipping = {'mode': clipping}
16058 if 'mode' in clipping:
16059 control_lines.append(f"-les_clip_mode {normalize_les_clip_mode(clipping['mode'])}")
16060 if 'max_cs' in clipping:
16061 control_lines.append(f"-les_clip_max_cs {format_flag_value(clipping['max_cs'])}")
16062 if 'min_viscosity_ratio' in clipping:
16063 control_lines.append(
16064 f"-les_min_viscosity_ratio {format_flag_value(clipping['min_viscosity_ratio'])}")
16065
16066 gradient_model = les_cfg.get('gradient_model')
16067 if gradient_model is not None:
16068 if not isinstance(gradient_model, dict):
16069 gradient_model = {'enabled': gradient_model}
16070 enabled = resolve_enabled_flag(gradient_model,
16071 "models.physics.turbulence.les.gradient_model")
16072 control_lines.append(f"-les_gradient_model {1 if enabled else 0}")
16073
16074def resolve_les_diagnostics(case_cfg: dict, monitor_cfg: dict, warn: bool = False) -> "dict | None":
16075 """!
16076 @brief Resolve the LES diagnostic log settings from monitor.yml or their deprecated location.
16077 @details The log is observation, not physics, so it is configured in monitor.yml
16078 `diagnostics.les`; case.yml `models.physics.turbulence.les.diagnostics` is read as a
16079 deprecated alias. Setting both is refused. `yoshizawa_ci` only scales the subgrid
16080 kinetic energy the log reports, so it belongs with the log.
16081 @param[in] case_cfg Parsed case configuration.
16082 @param[in] monitor_cfg Parsed monitor configuration.
16083 @param[in] warn Print a deprecation warning when the case.yml location is used.
16084 @return {"enabled", optional "cadence", optional "yoshizawa_ci"}, or None when unset.
16085 @throws ValueError for a duplicated, malformed, or out-of-range setting.
16086 """
16087 monitor_value = (((monitor_cfg or {}).get("diagnostics") or {}).get("les")
16088 if isinstance((monitor_cfg or {}).get("diagnostics"), dict) else None)
16089 les_cfg = ((((case_cfg or {}).get("models") or {}).get("physics") or {}).get("turbulence") or {}).get("les")
16090 case_value = les_cfg.get("diagnostics") if isinstance(les_cfg, dict) else None
16091 if monitor_value is not None and case_value is not None:
16092 raise ValueError("LES diagnostics are set in monitor.yml (diagnostics.les) and in their deprecated "
16093 "case.yml location (models.physics.turbulence.les.diagnostics); keep only monitor.yml.")
16094 value, label = ((monitor_value, "monitor.yml diagnostics.les") if monitor_value is not None
16095 else (case_value, "case.yml models.physics.turbulence.les.diagnostics"))
16096 if value is None:
16097 return None
16098 if not isinstance(value, dict):
16099 value = {"enabled": value}
16100 unknown = sorted(set(value) - {"enabled", "cadence", "yoshizawa_ci"})
16101 if unknown:
16102 raise ValueError(f"{label} has unsupported key(s): {unknown}. Use enabled, cadence, yoshizawa_ci.")
16103 resolved = {"enabled": resolve_enabled_flag(value, label)}
16104 if "cadence" in value:
16105 if isinstance(value["cadence"], bool) or not isinstance(value["cadence"], int) or value["cadence"] <= 0:
16106 raise ValueError(f"{label}.cadence must be a positive integer.")
16107 resolved["cadence"] = value["cadence"]
16108 if "yoshizawa_ci" in value:
16109 try:
16110 ci = _to_finite_float(value["yoshizawa_ci"], f"{label}.yoshizawa_ci")
16111 except (TypeError, ValueError) as exc:
16112 raise ValueError(f"{label}.yoshizawa_ci must be numeric.") from exc
16113 if ci < 0.0:
16114 raise ValueError(f"{label}.yoshizawa_ci must be non-negative.")
16115 resolved["yoshizawa_ci"] = ci
16116 if monitor_value is None and warn:
16117 print("[WARNING] case.yml models.physics.turbulence.les.diagnostics is deprecated; "
16118 "set it in monitor.yml as diagnostics.les.", file=sys.stderr)
16119 return resolved
16120
16121
16122def append_les_diagnostics_flags(diagnostics: "dict | None", control_lines: list):
16123 """!
16124 @brief Append the LES diagnostic log flags resolved by resolve_les_diagnostics().
16125 @param[in] diagnostics Resolved settings, or None to append nothing.
16126 @param[out] control_lines A list of strings to which C-flags will be appended.
16127 """
16128 if diagnostics is None:
16129 return
16130 control_lines.append(f"-les_diagnostics {'true' if diagnostics['enabled'] else 'false'}")
16131 if "cadence" in diagnostics:
16132 control_lines.append(f"-les_diagnostics_cadence {format_flag_value(diagnostics['cadence'])}")
16133 if "yoshizawa_ci" in diagnostics:
16134 control_lines.append(f"-les_yoshizawa_ci {format_flag_value(diagnostics['yoshizawa_ci'])}")
16135
16136def append_turbulence_flags(models: dict, control_lines: list, scales: dict = None):
16137 """!
16138 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
16139 @param[in] models Parsed case.yml `models` mapping.
16140 @param[out] control_lines A list of strings to which C-flags will be appended.
16141 @param[in] scales The case's reference scales. Required only when a physical
16142 roughness height is set.
16143 @throws RuntimeError when a roughness height is set and no scales were supplied.
16144 """
16145 turbulence_cfg = models.get('physics', {}).get('turbulence', {})
16146 if not turbulence_cfg:
16147 return
16148 if not isinstance(turbulence_cfg, dict):
16149 raise ValueError("models.physics.turbulence must be a mapping.")
16150
16151 les_cfg = turbulence_cfg.get('les')
16152 rans_cfg = turbulence_cfg.get('rans')
16153 wall_cfg = turbulence_cfg.get('wall_function')
16154 les_code = None
16155
16156 if isinstance(les_cfg, dict):
16157 enabled = resolve_enabled_flag(les_cfg, "models.physics.turbulence.les")
16158 model_value = les_cfg.get('model', 'constant_smagorinsky')
16159 les_code = normalize_les_model(model_value) if enabled else 0
16160 control_lines.append(f"-les {les_code}")
16161 append_les_parameter_flags(les_cfg, control_lines)
16162 elif les_cfg is not None:
16163 les_code = normalize_les_model(les_cfg)
16164 control_lines.append(f"-les {les_code}")
16165
16166 if rans_cfg is not None:
16167 raise ValueError(RANS_PLANNED_REFUSAL)
16168
16169 if isinstance(wall_cfg, dict):
16170 enabled = resolve_enabled_flag(wall_cfg, "models.physics.turbulence.wall_function")
16171 # The flag carries the model, with zero meaning disabled, so a selector change
16172 # reaches the runtime instead of being validated and dropped.
16173 wall_model = normalize_wall_function_model(wall_cfg.get('model'))
16174 control_lines.append(f"-wallfunction {wall_model if enabled else 0}")
16175 if 'roughness_height' in wall_cfg:
16176 if scales is None:
16177 raise RuntimeError("wall_function.roughness_height is a physical length and needs the case reference scales.")
16178 roughness = to_solver_units(
16179 wall_cfg['roughness_height'],
16180 ("case", "models", "physics", "turbulence", "wall_function", "roughness_height"), scales)
16181 control_lines.append(f"-wall_roughness {format_flag_value(roughness)}")
16182 elif wall_cfg is not None:
16183 control_lines.append(f"-wallfunction {format_flag_value(wall_cfg)}")
16184
16185def control_value(value, context: str):
16186 """!
16187 @brief Guard a value that is written verbatim into the generated control file.
16188
16189 @details PETSc reads the control file line by line, so a value containing a newline
16190 writes additional option lines - which defeats every key-based check by
16191 smuggling a whole new flag. Rejecting the value is the only safe handling;
16192 there is no quoting that makes a multi-line option meaningful.
16193 @param[in] value Configured value destined for a control line.
16194 @param[in] context YAML path, for the error message.
16195 @return The value unchanged when it is safe to emit.
16196 @throws SystemExit when the value would inject an option line.
16197 """
16198 text = str(value)
16199 if any(character in text for character in ("\n", "\r")):
16200 print(
16201 f"[FATAL] {context} contains a newline. Values written to the generated control "
16202 f"file must occupy a single line; a multi-line value would inject additional "
16203 f"PETSc options.",
16204 file=sys.stderr,
16205 )
16206 sys.exit(1)
16207 return value
16208
16209
16210def append_passthrough_flags(control_lines: list, options: dict):
16211 """!
16212 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
16213 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
16214 is skipped. All other values are emitted as "<flag> <value>".
16215 @param[out] control_lines The destination list of control-file lines.
16216 @param[in] options Mapping of raw CLI flags to values.
16217 """
16218 if not options:
16219 return
16220 for flag, value in options.items():
16221 if isinstance(flag, str) and flag.strip() in RESERVED_DIRECTORY_FLAGS:
16222 # Defense in depth: validation rejects these, but the emitter must never
16223 # write a run-directory flag from an unvalidated passthrough surface.
16224 raise ValueError(
16225 f"Refusing to emit reserved directory flag '{flag.strip()}' from passthrough "
16226 "options; run directories are fixed by the workspace contract."
16227 )
16228 if isinstance(value, bool):
16229 if value:
16230 control_lines.append(str(flag))
16231 continue
16232 control_lines.append(
16233 f"{flag} {control_value(format_flag_value(value), f'passthrough option {flag}')}"
16234 )
16235
16236
16237SOLVER_MONITORING_POISSON_FLAG_MAP = {
16238 "pic_true_residual": "-ps_ksp_pic_monitor_true_residual",
16239 "true_residual": "-ps_ksp_monitor_true_residual",
16240 "converged_reason": "-ps_ksp_converged_reason",
16241 "view": "-ps_ksp_view",
16242}
16243
16244SOLVER_MONITORING_MOMENTUM_FLAG_MAP = {
16245 "newton_krylov_history": "-mom_nk_pic_monitor",
16246 "snes_monitor": "-mom_nk_snes_monitor",
16247 "snes_converged_reason": "-mom_nk_snes_converged_reason",
16248 "ksp_monitor": "-mom_nk_ksp_monitor",
16249 "ksp_converged_reason": "-mom_nk_ksp_converged_reason",
16250}
16251
16252
16253def resolve_solver_monitoring_flags(monitor_cfg: dict) -> dict:
16254 """!
16255 @brief Resolve human-readable solver monitoring YAML to raw control flags.
16256 @param[in] monitor_cfg Parsed monitor.yml mapping.
16257 @return Mapping of raw C/PETSc flags to values.
16258 """
16259 solver_mon_cfg = monitor_cfg.get("solver_monitoring", {}) if isinstance(monitor_cfg, dict) else {}
16260 if solver_mon_cfg is None:
16261 return {}
16262 if not isinstance(solver_mon_cfg, dict):
16263 raise ValueError("monitor.solver_monitoring must be a mapping when provided.")
16264
16265 flags = {}
16266
16267 momentum_cfg = solver_mon_cfg.get("momentum", {})
16268 if momentum_cfg is None:
16269 momentum_cfg = {}
16270 if not isinstance(momentum_cfg, dict):
16271 raise ValueError("monitor.solver_monitoring.momentum must be a mapping when provided.")
16272 unknown_momentum = sorted(set(momentum_cfg.keys()) - set(SOLVER_MONITORING_MOMENTUM_FLAG_MAP.keys()))
16273 if unknown_momentum:
16274 raise ValueError(f"monitor.solver_monitoring.momentum has unsupported key(s): {unknown_momentum}.")
16275 for key, flag in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.items():
16276 if key in momentum_cfg:
16277 value = momentum_cfg[key]
16278 if not isinstance(value, bool):
16279 raise ValueError(f"monitor.solver_monitoring.momentum.{key} must be boolean.")
16280 flags[flag] = value
16281
16282 poisson_cfg = solver_mon_cfg.get("poisson", {})
16283 if poisson_cfg is None:
16284 poisson_cfg = {}
16285 if not isinstance(poisson_cfg, dict):
16286 raise ValueError("monitor.solver_monitoring.poisson must be a mapping when provided.")
16287 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
16288 if unknown_poisson:
16289 raise ValueError(f"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
16290 for key, flag in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
16291 if key in poisson_cfg:
16292 value = poisson_cfg[key]
16293 if not isinstance(value, bool):
16294 raise ValueError(f"monitor.solver_monitoring.poisson.{key} must be boolean.")
16295 flags[flag] = value
16296
16297 passthrough = solver_mon_cfg.get("petsc_passthrough_options", {})
16298 if passthrough is None:
16299 passthrough = {}
16300 if not isinstance(passthrough, dict):
16301 raise ValueError("monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
16302 flags.update(passthrough)
16303
16304 legacy_raw = {
16305 key: value
16306 for key, value in solver_mon_cfg.items()
16307 if isinstance(key, str) and key.startswith("-")
16308 }
16309 flags.update(legacy_raw)
16310
16311 unknown_top = sorted(
16312 key
16313 for key in solver_mon_cfg.keys()
16314 if key not in {"momentum", "poisson", "petsc_passthrough_options"} and not (isinstance(key, str) and key.startswith("-"))
16315 )
16316 if unknown_top:
16317 raise ValueError(
16318 "monitor.solver_monitoring has unsupported key(s): "
16319 f"{unknown_top}. Use 'momentum'/'poisson' for structured monitors or "
16320 "'petsc_passthrough_options' for raw PETSc flags."
16321 )
16322
16323 return flags
16324
16325
16326MAX_TRACED_PARTICLES = 64 # PICURV_MAX_TRACED_PARTICLES in include/variables.h
16327
16328
16329def resolve_traced_particle_ids(monitor_cfg: dict) -> list:
16330 """!
16331 @brief Return the sorted, distinct particle IDs of `logging.trace_particles`.
16332 @param[in] monitor_cfg Parsed monitor configuration.
16333 @return Sorted list of non-negative integer IDs; empty when tracing is off.
16334 @throws ValueError When the value is not a list of at most 64 non-negative integers.
16335 """
16336 logging_cfg = monitor_cfg.get('logging') if isinstance(monitor_cfg, dict) else None
16337 if not isinstance(logging_cfg, dict) or logging_cfg.get('trace_particles') is None:
16338 return []
16339 raw = logging_cfg['trace_particles']
16340 if not isinstance(raw, list) or any(isinstance(pid, bool) or not isinstance(pid, int) or pid < 0 for pid in raw):
16341 raise ValueError("'logging.trace_particles' must be a list of non-negative integer particle IDs "
16342 f"(got {raw!r}).")
16343 traced = sorted(set(raw))
16344 if len(traced) > MAX_TRACED_PARTICLES:
16345 raise ValueError(f"'logging.trace_particles' accepts at most {MAX_TRACED_PARTICLES} particle IDs "
16346 f"(got {len(traced)}).")
16347 return traced
16348
16349
16350def resolve_particle_console_output_frequency(io_cfg: dict) -> "int | None":
16351 """!
16352 @brief Return the effective particle-console snapshot cadence from monitor.yml.
16353 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
16354 @return Value returned by `resolve_particle_console_output_frequency()`.
16355 """
16356 if 'particle_console_output_frequency' in io_cfg:
16357 return io_cfg['particle_console_output_frequency']
16358 return io_cfg.get('data_output_frequency')
16359
16360def parse_and_add_model_flags(case_cfg: dict, control_lines: list):
16361 """!
16362 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
16363 @param[in] case_cfg The parsed case.yml configuration dictionary.
16364 @param[out] control_lines A list of strings to which C-flags will be appended.
16365 """
16366 models = case_cfg.get('models', {})
16367 FLAG_MAP = {
16368 'domain': {'blocks': '-nblk'},
16369 'physics.fsi': {'immersed': '-imm', 'moving_fsi': '-fsi'},
16370 'physics.particles': {'count': '-numParticles', 'random_seed': '-particle_random_seed'},
16371 }
16372 for section_path, flags in FLAG_MAP.items():
16373 current_level = models
16374 try:
16375 for key in section_path.split('.'): current_level = current_level[key]
16376 for yaml_key, flag in flags.items():
16377 if yaml_key in current_level:
16378 control_lines.append(f"{flag} {format_flag_value(current_level[yaml_key])}")
16379 except KeyError: continue
16380
16381 wall_cfg = (((models.get('physics') or {}).get('turbulence') or {}).get('wall_function'))
16382 needs_scales = isinstance(wall_cfg, dict) and 'roughness_height' in wall_cfg
16383 append_turbulence_flags(models, control_lines,
16384 resolve_reference_scales(case_cfg) if needs_scales else None)
16385
16386 if models.get('physics', {}).get('dimensionality') == '2D':
16387 control_lines.append("-TwoD 1")
16388
16389 particles_cfg = models.get('physics', {}).get('particles', {})
16390 p_init_mode_str = particles_cfg.get('init_mode', 'Surface')
16391 pinit_code = normalize_particle_init_mode(p_init_mode_str)
16392 control_lines.append(f"-pinit {pinit_code}")
16393 print(f" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
16394
16395 if pinit_code == 2:
16396 point_cfg = particles_cfg.get('point_source', {})
16397 if not isinstance(point_cfg, dict):
16398 raise ValueError("models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
16399 scales = resolve_reference_scales(case_cfg)
16400 point_path = ("case", "models", "physics", "particles", "point_source")
16401 try:
16402 psrc = [to_solver_units(point_cfg[axis], point_path + (axis,), scales) for axis in ("x", "y", "z")]
16403 except (KeyError, TypeError, ValueError):
16404 raise ValueError("PointSource init_mode requires numeric point_source.{x,y,z} values.")
16405 for axis, value in zip(("x", "y", "z"), psrc):
16406 control_lines.append(f"-psrc_{axis} {value}")
16407 print(f" - Particle Point Source: ({point_cfg['x']}, {point_cfg['y']}, {point_cfg['z']}) "
16408 f"-> solver units ({psrc[0]}, {psrc[1]}, {psrc[2]})")
16409
16410 if particles_cfg.get('fields'):
16411 particle_fields = normalize_particle_fields(particles_cfg['fields'])
16412 control_lines.append(f"-particle_fields_count {len(particle_fields)}")
16413 for index, entry in enumerate(particle_fields):
16414 control_lines.append(f"-particle_fields_{index}_name {entry['name']}")
16415 for component, expression in enumerate(entry["expressions"]):
16416 text = control_value(expression, f"models.physics.particles.fields.{entry['name']}")
16417 control_lines.append(f"-particle_fields_{index}_expr_{component} \"{text}\"")
16418 print(f" - Particle Field Initial Value: {entry['name']} = {' ; '.join(entry['expressions'])}")
16419
16420 p_restart_mode = particles_cfg.get('restart_mode')
16421 if p_restart_mode:
16422 p_restart_mode_normalized = str(p_restart_mode).lower()
16423 if p_restart_mode_normalized not in PARTICLE_RESTART_MODES:
16424 raise ValueError(f"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
16425 control_lines.append(f"-particle_restart_mode \"{p_restart_mode}\"")
16426
16427# PETSc KSP types whose iteration adapts to the input vector. Any of these used as
16428# the multigrid coarse solve (level_0) makes the MG preconditioner a nonlinear
16429# operator; see docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.
16430KRYLOV_KSP_TYPES = {
16431 "gmres", "fgmres", "lgmres", "dgmres", "pgmres", "gcr",
16432 "cg", "cgne", "cgs", "bcgs", "ibcgs", "fbcgs", "fbcgsr", "bcgsl",
16433 "tfqmr", "tcqmr", "minres", "symmlq", "cr", "lsqr", "pipecg", "pipefgmres",
16434}
16435
16436def parse_solver_config(solver_cfg: dict, scales: dict = None, case_cfg: dict = None) -> dict:
16437 """!
16438 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
16439 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
16440 @param[in] scales The case's reference scales. Required only when solver.yml sets a
16441 dimensional value, which is physical and is converted here.
16442 @param[in] case_cfg The parsed case.yml, which holds the scalar-transport parameters.
16443 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
16444 """
16445 flags = {}
16446
16447 def solver_value(path: tuple, value) -> float:
16448 """!
16449 @brief Convert one solver.yml value by its recorded dimension.
16450 @param[in] path Schema path under solver.yml.
16451 @param[in] value Value as written.
16452 @return The value in solver units.
16453 @throws RuntimeError when the value is dimensional and no scales were supplied; that
16454 is a caller error, kept distinct from the ValueError of an invalid value.
16455 """
16456 full_path = ("solver",) + path
16457 if input_quantity(full_path)[0] == DIMENSIONLESS:
16458 return _to_finite_float(value, ".".join(path))
16459 if scales is None:
16460 raise RuntimeError(f"{'.'.join(path)} is a physical value and needs the case reference scales.")
16461 return to_solver_units(value, full_path, scales)
16462 if 'operation_mode' in solver_cfg and isinstance(solver_cfg['operation_mode'], dict):
16463 op_mode = solver_cfg['operation_mode']
16464 if 'eulerian_field_source' in op_mode:
16465 normalized_source = normalize_eulerian_field_source(op_mode.get('eulerian_field_source'))
16466 flags['-euler_field_source'] = f"\"{normalized_source}\""
16467 if 'analytical_type' in op_mode and op_mode.get('analytical_type') is not None:
16468 normalized_analytical_type = normalize_analytical_type(op_mode.get('analytical_type'))
16469 flags['-analytical_type'] = f"\"{normalized_analytical_type}\""
16470 if normalized_analytical_type == "UNIFORM_FLOW":
16471 uniform_flow_cfg = op_mode.get('uniform_flow', {})
16472 if not isinstance(uniform_flow_cfg, dict):
16473 raise ValueError("operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
16474 try:
16475 for component in ("u", "v", "w"):
16476 flags[f'-analytical_uniform_{component}'] = solver_value(
16477 ("operation_mode", "uniform_flow", component), uniform_flow_cfg[component])
16478 except KeyError as exc:
16479 raise ValueError(f"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.") from exc
16480 except (TypeError, ValueError) as exc:
16481 raise ValueError("operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.") from exc
16482
16483 verification_cfg = solver_cfg.get('verification', {})
16484 if verification_cfg:
16485 if not isinstance(verification_cfg, dict):
16486 raise ValueError("verification must be a mapping when provided.")
16487 sources_cfg = verification_cfg.get('sources', {})
16488 if not isinstance(sources_cfg, dict):
16489 raise ValueError("verification.sources must be a mapping when provided.")
16490 diff_cfg = sources_cfg.get('diffusivity')
16491 if diff_cfg is not None:
16492 if not isinstance(diff_cfg, dict):
16493 raise ValueError("verification.sources.diffusivity must be a mapping.")
16494 try:
16495 flags['-verification_diffusivity_mode'] = f"\"{str(diff_cfg['mode']).strip().lower()}\""
16496 flags['-verification_diffusivity_profile'] = f"\"{str(diff_cfg['profile']).strip().upper()}\""
16497 for key in ("gamma0", "slope_x"):
16498 flags[f'-verification_diffusivity_{key}'] = solver_value(
16499 ("verification", "sources", "diffusivity", key), diff_cfg[key])
16500 except KeyError as exc:
16501 raise ValueError(f"verification.sources.diffusivity.{exc.args[0]} is required.") from exc
16502 except (TypeError, ValueError) as exc:
16503 raise ValueError("verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.") from exc
16504
16505 scalar_cfg = sources_cfg.get('scalar')
16506 if scalar_cfg is not None:
16507 if not isinstance(scalar_cfg, dict):
16508 raise ValueError("verification.sources.scalar must be a mapping.")
16509 try:
16510 flags['-verification_scalar_mode'] = f"\"{str(scalar_cfg['mode']).strip().lower()}\""
16511 flags['-verification_scalar_profile'] = f"\"{str(scalar_cfg['profile']).strip().upper()}\""
16512 except KeyError as exc:
16513 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
16514
16515 scalar_numeric_keys = {
16516 'CONSTANT': ('value',),
16517 'LINEAR_X': ('phi0', 'slope_x'),
16518 'SIN_PRODUCT': ('amplitude', 'kx', 'ky', 'kz'),
16519 }
16520 profile = str(scalar_cfg.get('profile', '')).strip().upper()
16521 for key in scalar_numeric_keys.get(profile, ()):
16522 try:
16523 flags[f'-verification_scalar_{key}'] = solver_value(
16524 ("verification", "sources", "scalar", key), scalar_cfg[key])
16525 except KeyError as exc:
16526 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
16527 except (TypeError, ValueError) as exc:
16528 raise ValueError(f"verification.sources.scalar.{key} must be numeric.") from exc
16529
16530 for key, value in resolve_scalar_transport(case_cfg, solver_cfg).items():
16531 flags[SCALAR_TRANSPORT_FLAGS[key]] = value
16532
16533 selected_solver = None
16534 if 'strategy' in solver_cfg:
16535 s = solver_cfg['strategy']
16536 if 'central_diff' in s:
16537 flags['-central'] = format_flag_value(s['central_diff'])
16538 # Preferred selector.
16539 if 'momentum_solver' in s:
16540 selected_solver = normalize_momentum_solver_type(s['momentum_solver'])
16541 elif 'implicit' in s:
16542 raise ValueError("Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
16543
16544 def _warn_inactive_absolute_tol(cfg: dict, where: str):
16545 """!
16546 @brief Warn when absolute_tol is set but cannot affect convergence.
16547
16548 absolute_tol bounds the velocity update. Since |dU| ~ dtau*|R|, bounding it
16549 absolutely is a disguised, step-size-dependent residual bound, so it takes no
16550 part once a residual tolerance is active -- which is now the default. Setting it
16551 therefore has no effect, and silently ineffective knobs are what this warning
16552 exists to prevent. See docs/pages/24_Dual_Time_Picard_Jameson_RK.md.
16553 @param[in] cfg Tolerance mapping to inspect for `absolute_tol` and the residual keys.
16554 @param[in] where Config path used to locate the offending key in the warning text.
16555 @return None; emits a warning on stderr when `absolute_tol` cannot take effect.
16556 """
16557 if 'absolute_tol' not in cfg:
16558 return
16559 # Residual convergence is enabled unless BOTH residual tolerances are explicitly
16560 # non-positive; their defaults (1e-8 / 1e-3) are positive.
16561 def _off(key):
16562 """!
16563 @brief Report whether a residual tolerance is explicitly disabled.
16564 @param[in] key Residual tolerance key to inspect.
16565 @return True when the key is present and non-positive.
16566 """
16567 v = cfg.get(key, None)
16568 try:
16569 return v is not None and float(v) <= 0.0
16570 except (TypeError, ValueError):
16571 return False
16572 if _off('residual_absolute_tol') and _off('residual_relative_tol'):
16573 return
16574 print(
16575 f"[WARNING] {where}.absolute_tol is set but takes no part in convergence while a "
16576 "residual tolerance is active (the default). It is retained only for the legacy "
16577 "update-only branch, which can converge falsely when dtau collapses. To control "
16578 "accuracy use residual_relative_tol / residual_absolute_tol. See "
16579 "docs/pages/24_Dual_Time_Picard_Jameson_RK.md.",
16580 file=sys.stderr,
16581 )
16582
16583 ms = solver_cfg.get('momentum_solver', {})
16584 if selected_solver is None:
16585 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
16586 flags['-mom_solver_type'] = f"\"{selected_solver}\""
16587
16588 if 'tolerances' in solver_cfg:
16589 t = solver_cfg['tolerances']
16590 tol_map = {
16591 'max_iterations': '-mom_max_pseudo_steps',
16592 'absolute_tol': '-mom_atol',
16593 'relative_tol': '-mom_rtol',
16594 'residual_absolute_tol': '-mom_resid_atol',
16595 'residual_relative_tol': '-mom_resid_rtol',
16596 'step_tol': '-imp_stol'
16597 }
16598 for key, flag in tol_map.items():
16599 if key in t:
16600 flags[flag] = t[key]
16601 _warn_inactive_absolute_tol(t, "tolerances")
16602
16603 def _append_dualtime_options(cfg: dict):
16604 """!
16605 @brief Append dualtime options.
16606 @param[in] cfg Argument passed to `_append_dualtime_options()`.
16607 """
16608 if 'max_pseudo_steps' in cfg:
16609 flags['-mom_max_pseudo_steps'] = cfg['max_pseudo_steps']
16610 if 'absolute_tol' in cfg:
16611 flags['-mom_atol'] = cfg['absolute_tol']
16612 _warn_inactive_absolute_tol(
16613 {**solver_cfg.get('tolerances', {}), **cfg},
16614 "momentum_solver.dual_time_picard_jameson_rk")
16615 if 'relative_tol' in cfg:
16616 flags['-mom_rtol'] = cfg['relative_tol']
16617 if 'step_tol' in cfg:
16618 flags['-imp_stol'] = cfg['step_tol']
16619 if 'pseudo_cfl' in cfg:
16620 pcfl = cfg['pseudo_cfl']
16621 if 'initial' in pcfl:
16622 flags['-pseudo_cfl'] = pcfl['initial']
16623 if 'minimum' in pcfl:
16624 flags['-min_pseudo_cfl'] = pcfl['minimum']
16625 if 'maximum' in pcfl:
16626 flags['-max_pseudo_cfl'] = pcfl['maximum']
16627 if 'growth_factor' in pcfl:
16628 flags['-pseudo_cfl_growth_factor'] = pcfl['growth_factor']
16629 if 'reduction_factor' in pcfl:
16630 flags['-pseudo_cfl_reduction_factor'] = pcfl['reduction_factor']
16631 if 'jameson_residual_noise_allowance_factor' in cfg:
16632 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['jameson_residual_noise_allowance_factor']
16633 elif 'rk4_residual_noise_allowance_factor' in cfg:
16634 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['rk4_residual_noise_allowance_factor']
16635 if 'ratio_ema_alpha' in cfg:
16636 flags['-mom_ratio_ema_alpha'] = cfg['ratio_ema_alpha']
16637
16638 def _append_newton_krylov_options(cfg: dict):
16639 """!
16640 @brief Append validated structured Newton--Krylov PETSc options.
16641 @param[in] cfg Structured Newton--Krylov mapping.
16642 """
16644 jacobian = cfg["jacobian"]
16645 flags["-mom_nk_jacobian_type"] = jacobian["type"]
16646 flags["-mom_nk_jacobian_fd_mode"] = jacobian["finite_difference"]["mode"]
16647 preconditioner = cfg["preconditioner"]
16648 flags["-mom_nk_preconditioner_model"] = preconditioner["model"]
16649 flags["-mom_nk_preconditioner_structure"] = preconditioner["structure"]["type"]
16650 nonlinear = cfg["nonlinear_solver"]
16651 nonlinear_map = {
16652 "method": "-mom_nk_snes_type",
16653 "absolute_tolerance": "-mom_nk_snes_atol",
16654 "relative_tolerance": "-mom_nk_snes_rtol",
16655 "step_tolerance": "-mom_nk_snes_stol",
16656 "max_iterations": "-mom_nk_snes_max_it",
16657 }
16658 for key, flag in nonlinear_map.items():
16659 if key in nonlinear:
16660 flags[flag] = nonlinear[key]
16661 line_search = nonlinear.get("line_search", {})
16662 if "type" in line_search:
16663 flags["-mom_nk_snes_linesearch_type"] = line_search["type"]
16664 ew = nonlinear.get("eisenstat_walker")
16665 if ew:
16666 flags["-mom_nk_snes_ksp_ew"] = ew["enabled"]
16667 if ew["enabled"]:
16668 ew_map = {
16669 "version": "-mom_nk_snes_ksp_ew_version",
16670 "initial_relative_tolerance": "-mom_nk_snes_ksp_ew_rtol0",
16671 "maximum_relative_tolerance": "-mom_nk_snes_ksp_ew_rtolmax",
16672 "gamma": "-mom_nk_snes_ksp_ew_gamma",
16673 "exponent": "-mom_nk_snes_ksp_ew_alpha",
16674 "safeguard_exponent": "-mom_nk_snes_ksp_ew_alpha2",
16675 "safeguard_threshold": "-mom_nk_snes_ksp_ew_threshold",
16676 }
16677 for key, flag in ew_map.items():
16678 if key in ew:
16679 flags[flag] = ew[key]
16680
16681 linear = cfg["linear_solver"]
16682 linear_map = {
16683 "method": "-mom_nk_ksp_type",
16684 "absolute_tolerance": "-mom_nk_ksp_atol",
16685 "relative_tolerance": "-mom_nk_ksp_rtol",
16686 "max_iterations": "-mom_nk_ksp_max_it",
16687 }
16688 for key, flag in linear_map.items():
16689 if key in linear:
16690 flags[flag] = linear[key]
16691 gmres = linear.get("gmres", {})
16692 if "restart" in gmres:
16693 flags["-mom_nk_ksp_gmres_restart"] = gmres["restart"]
16694
16695 if isinstance(ms, dict):
16696 allowed_ms_keys = {'type', 'dual_time_picard_jameson_rk', 'dual_time_picard_rk4', 'newton_krylov'}
16697 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
16698 if unknown_ms_keys:
16699 raise ValueError(
16700 f"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
16701 "Currently supported blocks: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
16702 )
16703
16704 if 'dual_time_picard_jameson_rk' in ms and 'dual_time_picard_rk4' in ms:
16705 raise ValueError(
16706 "Use only momentum_solver.dual_time_picard_jameson_rk; "
16707 "do not also set its deprecated dual_time_picard_rk4 alias."
16708 )
16709 dt_picard_cfg = ms.get('dual_time_picard_jameson_rk', ms.get('dual_time_picard_rk4'))
16710 if dt_picard_cfg is not None:
16711 if selected_solver != "DUALTIME_PICARD_JAMESON_RK":
16712 raise ValueError(
16713 f"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
16714 )
16715 if not isinstance(dt_picard_cfg, dict):
16716 raise ValueError("momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
16717 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
16718 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
16719 raise ValueError(
16720 "Use only jameson_residual_noise_allowance_factor; "
16721 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
16722 )
16723 _append_dualtime_options(dt_picard_cfg)
16724 newton_cfg = ms.get('newton_krylov')
16725 if newton_cfg is not None:
16726 if selected_solver != "newton_krylov":
16727 raise ValueError(
16728 f"momentum_solver.newton_krylov is set but selected solver is {selected_solver}."
16729 )
16730 _append_newton_krylov_options(newton_cfg)
16731 def _normalize_poisson_method(value) -> str:
16732 """!
16733 @brief Normalize a user-facing Poisson linear-solver method name.
16734 @param[in] value Method value from the solver YAML.
16735 @return Lowercase PETSc KSP method token.
16736 """
16737 method = str(value).strip().lower()
16738 if not method:
16739 raise ValueError("poisson_solver.method cannot be empty.")
16740 if method in POISSON_KSP_REFUSED:
16741 raise ValueError(
16742 f"poisson_solver.method '{method}' {POISSON_KSP_REFUSED[method]}: PICurv's "
16743 "multigrid preconditioner is not a single fixed linear operator, and with it "
16744 f"'{method}' reports convergence while the true residual stalls, so the "
16745 "projection uses the wrong pressure. Use 'fgmres' (default) or 'cg'."
16746 )
16747 if method not in POISSON_KSP_METHODS:
16748 raise ValueError(
16749 f"poisson_solver.method '{method}' is not one of {sorted(POISSON_KSP_METHODS)}, "
16750 "the methods verified with the multigrid preconditioner. Another PETSc KSP type "
16751 "can still be set through petsc_passthrough_options (-ps_ksp_type), unverified."
16752 )
16753 return method
16754
16755 def _normalize_poisson_preconditioner(value) -> str:
16756 """!
16757 @brief Normalize and validate the outer Poisson preconditioner name.
16758 @param[in] value Preconditioner value from the solver YAML.
16759 @return PETSc PC token for the supported outer preconditioner.
16760 """
16761 pc = str(value).strip().lower()
16762 pc = POISSON_PRECONDITIONER_SPELLINGS.get(pc, pc)
16763 if pc not in POISSON_PRECONDITIONER_TYPES:
16764 raise ValueError(
16765 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
16766 "The runtime Poisson solver still assumes PETSc PCMG setup."
16767 )
16768 return "mg"
16769
16770 def _warn_if_krylov_coarse_solver(ksp_type, source_key: str):
16771 """!
16772 @brief Warn when the coarsest multigrid level is given a Krylov solver.
16773 @param[in] ksp_type PETSc KSP token configured for `level_0`.
16774 @param[in] source_key Name of the source YAML block, used in the message.
16775 @details level_0 is the coarse solve at the base of the V-cycle, not a
16776 smoother. A Krylov method there makes the multigrid
16777 preconditioner a nonlinear operator, which decouples the outer
16778 KSP's tracked residual from the true residual b-Ax. This stays a
16779 warning rather than an error because it remains legitimate at
16780 large scale when tolerances are set against the true residual.
16781 """
16782 if str(ksp_type).strip().lower() not in KRYLOV_KSP_TYPES:
16783 return
16784 print(
16785 f"[WARNING] {source_key}.multigrid.level_solvers.level_0.method = '{ksp_type}' "
16786 "is a Krylov method. level_0 is the multigrid COARSE SOLVE, not a smoother, "
16787 "so a Krylov method there makes the preconditioner nonlinear and the outer "
16788 "KSP's tracked residual can stop matching the true residual b-Ax. "
16789 "Prefer {method: preonly, preconditioner: redundant}. If this is deliberate, "
16790 "enable solver_monitoring.poisson.pic_true_residual and set tolerances against "
16791 "the true residual. See docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.",
16792 file=sys.stderr,
16793 )
16794
16795 def _poisson_level_number(level_name) -> int:
16796 """!
16797 @brief Extract the numeric suffix from a `level_N` multigrid level key.
16798 @param[in] level_name YAML level key supplied by the user.
16799 @return Numeric level suffix.
16800 """
16801 text = str(level_name).strip()
16802 match = re.fullmatch(r"level_(\d+)", text)
16803 if not match:
16804 raise ValueError(f"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
16805 return int(match.group(1))
16806
16807 def _append_poisson_solver_flags(ps: dict, source_key: str):
16808 """!
16809 @brief Append structured Poisson solver options to the flat PETSc flag map.
16810 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
16811 @param[in] source_key Name of the source YAML block, used in error messages.
16812 """
16813 if not isinstance(ps, dict):
16814 raise ValueError(f"{source_key} must be a mapping when provided.")
16815
16816 method = None
16817 if 'method' in ps:
16818 method = _normalize_poisson_method(ps['method'])
16819 flags['-ps_ksp_type'] = method
16820 flags.update(POISSON_KSP_METHODS[method])
16821 if 'absolute_tolerance' in ps:
16822 flags['-ps_ksp_atol'] = ps['absolute_tolerance']
16823 if 'relative_tolerance' in ps:
16824 flags['-ps_ksp_rtol'] = ps['relative_tolerance']
16825 if 'max_iterations' in ps:
16826 flags['-ps_ksp_max_it'] = ps['max_iterations']
16827 if 'tolerance' in ps:
16828 raise ValueError(
16829 f"{source_key}.tolerance is not a solver control: the runtime read it and never "
16830 "used it. Use absolute_tolerance (-> -ps_ksp_atol) and relative_tolerance "
16831 "(-> -ps_ksp_rtol), which the Poisson KSP applies."
16832 )
16833
16834 gmres_cfg = ps.get('gmres', {})
16835 if gmres_cfg is not None:
16836 if not isinstance(gmres_cfg, dict):
16837 raise ValueError(f"{source_key}.gmres must be a mapping when provided.")
16838 if 'restart' in gmres_cfg:
16839 if method is None:
16840 method = _normalize_poisson_method(ps.get('method', 'fgmres'))
16841 flags.setdefault('-ps_ksp_type', method)
16842 if method not in GMRES_RESTART_METHODS:
16843 raise ValueError(
16844 f"{source_key}.gmres.restart is valid only when {source_key}.method "
16845 "is one of 'gmres', 'fgmres', or 'lgmres'."
16846 )
16847 flags['-ps_ksp_gmres_restart'] = gmres_cfg['restart']
16848
16849 preconditioner_cfg = ps.get('preconditioner', {})
16850 if preconditioner_cfg:
16851 if not isinstance(preconditioner_cfg, dict):
16852 raise ValueError(f"{source_key}.preconditioner must be a mapping when provided.")
16853 if 'type' in preconditioner_cfg:
16854 flags['-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg['type'])
16855
16856 if 'multigrid' in ps:
16857 mg = ps['multigrid']
16858 if not isinstance(mg, dict):
16859 raise ValueError(f"{source_key}.multigrid must be a mapping when provided.")
16860 mg_map = {'levels': '-mg_level', 'pre_sweeps': '-mg_pre_it', 'post_sweeps': '-mg_post_it'}
16861 for key, flag in mg_map.items():
16862 if key in mg: flags[flag] = mg[key]
16863 if 'cycle' in mg:
16864 cycle = str(mg['cycle']).strip().lower()
16865 if cycle not in {"v"}:
16866 raise ValueError(f"{source_key}.multigrid.cycle currently supports only 'v'.")
16867 if 'mode' in mg:
16868 mode = str(mg['mode']).strip().lower()
16869 if mode not in {"multiplicative"}:
16870 raise ValueError(f"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
16871 if 'semi_coarsening' in mg:
16872 sc = mg['semi_coarsening']
16873 if not isinstance(sc, dict):
16874 raise ValueError(f"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
16875 if 'i' in sc: flags['-mg_i_semi'] = format_flag_value(sc['i'])
16876 if 'j' in sc: flags['-mg_j_semi'] = format_flag_value(sc['j'])
16877 if 'k' in sc: flags['-mg_k_semi'] = format_flag_value(sc['k'])
16878 if 'level_solvers' in mg:
16879 level_solvers = mg['level_solvers']
16880 if not isinstance(level_solvers, dict):
16881 raise ValueError(f"{source_key}.multigrid.level_solvers must be a mapping when provided.")
16882 for level_name, settings in level_solvers.items():
16883 if not isinstance(settings, dict):
16884 raise ValueError(f"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
16885 level_num = _poisson_level_number(level_name)
16886 for key, value in settings.items():
16887 # PETSc prefixes every per-level KSP control with ksp_; emitted
16888 # bare, max_it/rtol/atol were left unused and silently ignored.
16889 mapped_key = {
16890 'method': 'ksp_type', 'preconditioner': 'pc_type',
16891 'max_it': 'ksp_max_it', 'rtol': 'ksp_rtol', 'atol': 'ksp_atol',
16892 }.get(key, key)
16893 # PETSc names the coarsest solver separately from positive levels.
16894 if level_num == 0:
16895 prefix = "-ps_mg_coarse_"
16896 if mapped_key == 'ksp_type':
16897 _warn_if_krylov_coarse_solver(value, source_key)
16898 else:
16899 prefix = f"-ps_mg_levels_{level_num}_"
16900 flags[f"{prefix}{mapped_key}"] = format_flag_value(value)
16901
16902 if 'poisson_solver' in solver_cfg and 'pressure_solver' in solver_cfg:
16903 if solver_cfg['poisson_solver'] != solver_cfg['pressure_solver']:
16904 raise ValueError(
16905 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
16906 "Use 'poisson_solver' only, or make the legacy alias identical."
16907 )
16908 poisson_cfg = solver_cfg.get('poisson_solver', solver_cfg.get('pressure_solver'))
16909 if poisson_cfg is not None:
16910 source_key = 'poisson_solver' if 'poisson_solver' in solver_cfg else 'pressure_solver'
16911 _append_poisson_solver_flags(poisson_cfg, source_key)
16912 interp_cfg = solver_cfg.get('interpolation', {})
16913 if isinstance(interp_cfg, dict):
16914 interp_method_str = interp_cfg.get('method', 'Trilinear')
16915 else:
16916 interp_method_str = 'Trilinear'
16917 interp_code = normalize_interpolation_method(interp_method_str)
16918 flags['-interpolation_method'] = interp_code
16919 print(f" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
16920
16921 if 'petsc_passthrough_options' in solver_cfg:
16922 passthrough = solver_cfg['petsc_passthrough_options']
16923 if passthrough is None:
16924 passthrough = {}
16925 if not isinstance(passthrough, dict):
16926 raise ValueError("petsc_passthrough_options must be a mapping when provided.")
16927 if passthrough:
16928 for key, value in passthrough.items():
16929 if str(key).strip() == "-ps_mg_coarse_ksp_type":
16930 _warn_if_krylov_coarse_solver(value, "petsc_passthrough_options")
16931 flags[key] = value if isinstance(value, bool) else format_flag_value(value)
16932 summary_bits = []
16933 if '-ps_ksp_type' in flags:
16934 summary_bits.append(f"method={flags['-ps_ksp_type']}")
16935 if '-ps_ksp_atol' in flags:
16936 summary_bits.append(f"atol={flags['-ps_ksp_atol']}")
16937 if '-ps_ksp_rtol' in flags:
16938 summary_bits.append(f"rtol={flags['-ps_ksp_rtol']}")
16939 if '-ps_ksp_max_it' in flags:
16940 summary_bits.append(f"max_it={flags['-ps_ksp_max_it']}")
16941 if '-mg_level' in flags:
16942 summary_bits.append(f"mg_levels={flags['-mg_level']}")
16943 if summary_bits:
16944 print(f" - Poisson Solver: {', '.join(summary_bits)}")
16945 if selected_solver == "DUALTIME_PICARD_JAMESON_RK":
16946 dualtime_bits = []
16947 for label, flag in (
16948 ("initial_pseudo_cfl", "-pseudo_cfl"),
16949 ("pseudo_cfl_range", None),
16950 ("max_pseudo_steps", "-mom_max_pseudo_steps"),
16951 ):
16952 if flag and flag in flags:
16953 dualtime_bits.append(f"{label}={flags[flag]}")
16954 elif label == "pseudo_cfl_range" and "-min_pseudo_cfl" in flags and "-max_pseudo_cfl" in flags:
16955 dualtime_bits.append(f"pseudo_cfl_range=[{flags['-min_pseudo_cfl']}, {flags['-max_pseudo_cfl']}]")
16956 print(" - Momentum Solver: Dual Time Picard Jameson RK" +
16957 (f" ({', '.join(dualtime_bits)})" if dualtime_bits else ""))
16958 elif selected_solver == "newton_krylov":
16959 newton_bits = []
16960 for label, flag in (
16961 ("jacobian", "-mom_nk_jacobian_type"),
16962 ("nonlinear", "-mom_nk_snes_type"),
16963 ("linear", "-mom_nk_ksp_type"),
16964 ("preconditioner", "-mom_nk_preconditioner_model"),
16965 ):
16966 if flag in flags:
16967 newton_bits.append(f"{label}={flags[flag]}")
16968 print(" - Momentum Solver: Newton Krylov" +
16969 (f" ({', '.join(newton_bits)})" if newton_bits else " (PETSc defaults)"))
16970 else:
16971 print(" - Momentum Solver: Explicit RK (no pseudo-time controller)")
16972 return flags
16973
16974def generate_solver_control_file(run_dir, run_id, configs, num_procs, monitor_files,
16975 restart_source_dir=None, continue_mode=False,
16976 config_dir: str = None):
16977 """!
16978 @brief Generates the main .control file for the C-solver.
16979 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
16980 into a single, machine-readable file of command-line flags.
16981 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
16982 @param[in] run_id Argument passed to `generate_solver_control_file()`.
16983 @param[in] configs Argument passed to `generate_solver_control_file()`.
16984 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
16985 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
16986 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
16987 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
16988 @param[in] config_dir Optional configuration revision directory.
16989 @return Value returned by `generate_solver_control_file()`.
16990 """
16991 print("[INFO] Generating master solver control file...")
16992 case_cfg, solver_cfg, monitor_cfg = configs['case'], configs['solver'], configs['monitor']
16993 source_files = {'Case': configs['case_path'], 'Solver': configs['solver_path'], 'Monitor': configs['monitor_path']}
16994
16995 control_lines = []
16996 try:
16997 props, run_ctrl = case_cfg['properties'], case_cfg['run_control']
16998 scales, fluid, ic = props['scaling'], props['fluid'], props['initial_conditions']
16999 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
17000 fluid_scaling = resolve_fluid_scaling(case_cfg)
17001 L_ref = fluid_scaling["length_ref"]
17002 U_ref = fluid_scaling["velocity_ref"]
17003 rho = fluid_scaling["density"]
17004 reynolds = fluid_scaling["reynolds"]
17005 dt_nondim = to_solver_units(run_ctrl['dt_physical'], ("case", "run_control", "dt_physical"), fluid_scaling)
17006 grid_length_scale = input_reference_scale(("case", "grid", "source_file"), fluid_scaling)
17007 print(f" - Reynolds Number (Re) = {reynolds:.4f}")
17008 print(f" - Non-Dimensional dt* = {dt_nondim:.6f}")
17009 eulerian_source = normalize_eulerian_field_source(
17010 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
17011 )
17012 start_step = int(run_ctrl.get("start_step", 0) or 0)
17013 ic_is_authoritative = eulerian_source == "solve" and start_step == 0
17014 ic_cli = []
17015 if ic_is_authoritative:
17017 ic, prepared_blocks, fluid_scaling,
17018 provider_context={"kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]},
17019 )
17020 finit_mode_str = resolved_ic["label"]
17021 finit_code = resolved_ic["finit"]
17022 ic_params = resolved_ic["cli_params"]
17023 print(f" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
17024 if "ucont_x" in ic_params:
17025 ic_cli.extend([
17026 f"-ucont_x {ic_params['ucont_x']}",
17027 f"-ucont_y {ic_params['ucont_y']}",
17028 f"-ucont_z {ic_params['ucont_z']}",
17029 ])
17030 if "ic_velocity_physical" in ic_params:
17031 ic_cli.append(f"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
17032 if "flow_direction" in ic_params:
17033 ic_cli.append(f"-flow_direction {ic_params['flow_direction']}")
17034 else:
17035 source = (f"the run starts from the checkpoint at step {start_step}" if start_step > 0
17036 else f"eulerian_field_source={eulerian_source!r} reads stored fields")
17037 print(f"[INFO] Initial condition not applied: {source}.")
17038 finit_code = 0
17039 resolved_ic = None
17040 control_lines.extend([
17041 f"-start_step {run_ctrl['start_step']}", f"-totalsteps {run_ctrl['total_steps']}",
17042 f"-ren {reynolds}", f"-dt {dt_nondim}", f"-finit {finit_code}",
17043 *ic_cli,
17044 f"-scaling_L_ref {L_ref}", f"-scaling_U_ref {U_ref}", f"-scaling_rho_ref {rho}"
17045 ])
17046 except (KeyError, TypeError, ZeroDivisionError, ValueError) as e:
17047 print(f"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
17048 sys.exit(1)
17049
17050 # --- CORRECTED: Add paths for whitelist and profile files ---
17051 if monitor_files.get("whitelist"):
17052 control_lines.append(f"-whitelist_config_file {monitor_files['whitelist']}")
17053 if monitor_files.get("profile"):
17054 control_lines.append(f"-profile_config_file {monitor_files['profile']}")
17056 control_lines.extend(resolve_field_statistics_flags(monitor_cfg, case_cfg))
17057 profiling_cfg = monitor_files.get("profiling", {})
17058 control_lines.append(f"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
17059 control_lines.append(f"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
17060 if profiling_cfg.get("helpers"):
17061 control_lines.append(f"-profiling_helpers {','.join(profiling_cfg['helpers'])}")
17062 diagnostics_cfg = resolve_diagnostics_config(monitor_cfg)
17063 memory_log_cfg = diagnostics_cfg["runtime_memory_log"]
17064 control_lines.append(f"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
17065
17066 walltime_guard_policy = configs.get("walltime_guard_policy")
17067 if walltime_guard_policy is not None:
17068 control_lines.extend(
17069 [
17070 f"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
17071 f"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
17072 f"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
17073 f"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
17074 f"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
17075 ]
17076 )
17077
17078 grid_cfg = case_cfg.get('grid', {})
17079 grid_mode = grid_cfg.get('mode')
17080 expected_nblk = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
17081
17082 if grid_mode == 'file':
17083 print("[INFO] Grid Mode: Using external file...")
17084 case_file_dir = os.path.dirname(configs['case_path'])
17085 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
17086 if os.path.isfile(nondim_grid_path):
17087 print(f"[INFO] Reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
17088 control_lines.append(f"-grid_file {nondim_grid_path}")
17089 else:
17090 source_grid = _resolve_case_relative_path(grid_cfg['source_file'], case_file_dir)
17091 grid_for_validation = source_grid
17092 try:
17094 grid_for_validation, nondim_grid_path, grid_length_scale, expected_nblk=expected_nblk
17095 )
17096 print(
17097 f"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
17098 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
17099 )
17100 control_lines.append(f"-grid_file {nondim_grid_path}")
17101 except Exception as e:
17102 print(f"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
17103 sys.exit(1)
17104 elif grid_mode == 'grid_gen':
17105 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
17106 if os.path.isfile(nondim_grid_path):
17107 print(f"[INFO] Grid Mode: grid.gen, reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
17108 control_lines.append(f"-grid_file {nondim_grid_path}")
17109 else:
17110 print("[INFO] Grid Mode: Generating external grid via grid.gen...")
17111 try:
17112 # grid.gen already converts ncells_* inputs into node-count PICGRID dims.
17113 generated_grid = run_grid_generator(
17114 configs['case_path'], run_dir, grid_cfg, case_cfg=case_cfg)
17116 generated_grid, nondim_grid_path, grid_length_scale, expected_nblk=expected_nblk
17117 )
17118 print(
17119 f"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
17120 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
17121 )
17122 control_lines.append(f"-grid_file {nondim_grid_path}")
17123 except Exception as e:
17124 print(f"[FATAL] Grid generation failed: {e}", file=sys.stderr)
17125 sys.exit(1)
17126 elif grid_mode == 'programmatic_c':
17127 print("[INFO] Grid Mode: Programmatic C...")
17128 grid_settings = translate_programmatic_grid_settings(grid_cfg.get('programmatic_settings', {}))
17129 control_lines.append("-grid")
17130 for p_key in GRID_DA_PROCESSOR_KEYS:
17131 grid_settings.pop(p_key, None)
17132 for key, value in grid_settings.items(): control_lines.append(f"-{key} {format_flag_value(value)}")
17133 if resolved_ic and is_generated_ic_provider(resolved_ic) and ic_is_authoritative:
17134 # The simulator builds this grid itself and never reads a file for it; this
17135 # bridge exists only so the Python IC generator (a separate process) can see
17136 # the same node coordinates. generate_picgrid_from_programmatic_settings uses
17137 # the identical formula as ComputeStretchedCoord in src/grid.c, so the two are
17138 # guaranteed to agree. Nothing above is changed: -grid_file is never emitted,
17139 # so the solver still builds its own grid exactly as before.
17140 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
17141 if not os.path.isfile(nondim_grid_path):
17142 try:
17144 grid_cfg.get('programmatic_settings', {}), nondim_grid_path,
17145 input_reference_scale(("case", "grid", "programmatic_settings", "xMins"), fluid_scaling),
17146 )
17147 print(
17148 "[SUCCESS] Materialized a bridge grid for the Python initial-condition "
17149 f"provider: {os.path.relpath(nondim_grid_path)}"
17150 )
17151 except Exception as e:
17152 print(f"[FATAL] Failed to materialize bridge grid for initial-condition generator: {e}",
17153 file=sys.stderr)
17154 sys.exit(1)
17155 else:
17156 raise ValueError(f"Unknown or missing grid mode '{grid_mode}' in case.yml.")
17157
17158 if resolved_ic and (resolved_ic["kind"] == "file" or is_generated_ic_provider(resolved_ic)):
17159 try:
17160 staged_ic = stage_initial_condition_file(run_dir, configs["case_path"], resolved_ic)
17161 except Exception as e:
17162 print(f"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
17163 sys.exit(1)
17164 control_lines.extend([
17165 f"-ic_field {resolved_ic['field_code']}",
17166 f"-ic_dir {staged_ic['directory']}",
17167 ])
17168 print(f" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
17169
17170 try:
17171 bcs_files = generate_multi_block_bcs(
17172 run_dir, run_id, case_cfg, source_files, config_dir=config_dir
17173 )
17174 except ValueError as e:
17175 print(f"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
17176 sys.exit(1)
17177 control_lines.append(f"-bcs_files \"{','.join(bcs_files)}\"")
17178
17179 configs["decomposition_resolved"] = append_grid_da_processor_layout(
17180 control_lines, grid_cfg, num_procs,
17181 block_dims=resolve_grid_node_dims(
17182 case_cfg, configs["case_path"], os.path.join(run_dir, "inputs", "grid", "grid.run")),
17183 solver_cfg=solver_cfg, any_periodic=case_declares_periodic_axis(case_cfg),
17184 explicit=configs.get("decomposition"), cluster_cfg=configs.get("cluster_cfg"))
17185
17186 parse_and_add_model_flags(case_cfg, control_lines)
17187 append_les_diagnostics_flags(resolve_les_diagnostics(case_cfg, configs.get('monitor') or {}), control_lines)
17188
17189 if 'solver_parameters' in case_cfg:
17190 params = case_cfg['solver_parameters']
17191 if params:
17192 for key, value in params.items():
17193 control_lines.append(f"{key} {format_flag_value(value)}")
17194
17195 try:
17196 solver_flags = parse_solver_config(solver_cfg, resolve_reference_scales(case_cfg), case_cfg)
17197 except ValueError as e:
17198 print(f"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
17199 sys.exit(1)
17200 append_passthrough_flags(control_lines, solver_flags)
17201
17202 try:
17203 solver_monitoring_flags = resolve_solver_monitoring_flags(monitor_cfg)
17204 except ValueError as e:
17205 print(f"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
17206 sys.exit(1)
17207 # Monitors for a solver that does not run would hand PETSc options nothing reads: the
17208 # momentum ones are Newton-Krylov's, and neither momentum nor Poisson solves when the
17209 # Eulerian field is loaded or analytical.
17210 solves_flow = solver_flags.get('-euler_field_source', '"solve"') == '"solve"'
17211 if not solves_flow:
17212 inactive = [flag for flag in (*SOLVER_MONITORING_MOMENTUM_FLAG_MAP.values(),
17213 *SOLVER_MONITORING_POISSON_FLAG_MAP.values())
17214 if solver_monitoring_flags.pop(flag, None) is not None]
17215 if inactive:
17216 print("[INFO] monitor.yml solver_monitoring applies to solved flow only; not passed "
17217 "with a loaded or analytical Eulerian field.")
17218 elif solver_flags.get('-mom_solver_type') != '"newton_krylov"':
17219 inactive = [flag for flag in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.values()
17220 if solver_monitoring_flags.pop(flag, None) is not None]
17221 if inactive:
17222 print("[INFO] monitor.yml solver_monitoring.momentum applies to Newton-Krylov only; "
17223 "not passed to this momentum solver.")
17224 append_passthrough_flags(control_lines, solver_monitoring_flags)
17225
17226 io_cfg = monitor_cfg.get('io', {})
17227 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
17228 if 'data_output_frequency' in io_cfg: control_lines.append(f"-tio {io_cfg['data_output_frequency']}")
17229 # The run's first and last states are checkpointed unless turned off; cadence and
17230 # signal checkpoints do not depend on these.
17231 for key in ('write_initial_checkpoint', 'write_final_checkpoint'):
17232 if key in io_cfg:
17233 control_lines.append(f"-{key} {str(bool(io_cfg[key])).lower()}")
17234 if particle_console_output_freq is not None:
17235 control_lines.append(f"-particle_console_output_freq {particle_console_output_freq}")
17236 statistics_console_output_freq = resolve_statistics_console_output_frequency(io_cfg)
17237 if statistics_console_output_freq is not None:
17238 control_lines.append(f"-statistics_console_output_freq {statistics_console_output_freq}")
17239 if 'particle_log_interval' in io_cfg: control_lines.append(f"-logfreq {io_cfg['particle_log_interval']}")
17240 traced = resolve_traced_particle_ids(monitor_cfg)
17241 if traced:
17242 control_lines.append(f"-picurv_trace_particles {','.join(str(pid) for pid in traced)}")
17243 if restart_source_dir:
17244 expected_restart = os.path.abspath(
17245 os.path.join(run_dir, CANONICAL_RUN_PATHS["restart"])
17246 )
17247 if os.path.abspath(restart_source_dir) != expected_restart:
17248 raise ValueError(
17249 "Restart data was not materialized in the canonical run input: "
17250 f"expected {expected_restart}, got {restart_source_dir}."
17251 )
17252 if continue_mode:
17253 control_lines.append("-continue_mode true")
17254 elif str(configs.get("statistics_state", "reset")).lower() == "carry":
17255 control_lines.append("-field_statistics_continue true")
17256
17257 # Emitted last, and always: PETSc takes the final occurrence of an option, so a
17258 # validated directory written here wins over anything earlier in the file. Writing
17259 # them unconditionally also removes the "omitted default" gap, where an absent flag
17260 # let an environment variable or PETSc configuration choose the directory instead.
17261 control_lines.append("")
17262 control_lines.append("# Canonical run-owned directories are fixed by the workspace contract.")
17263 control_lines.append(f"-output_dir {CANONICAL_RUN_PATHS['output']}")
17264 control_lines.append(f"-restart_dir {CANONICAL_RUN_PATHS['restart']}")
17265 control_lines.append(f"-log_dir {CANONICAL_RUN_PATHS['logs']}")
17266 control_lines.append(f"-analysis_dir {CANONICAL_RUN_PATHS['metrics']}")
17267
17268 final_content = generate_header(run_id, source_files) + "\n".join(control_lines)
17269 config_dir = config_dir or os.path.join(run_dir, "config")
17270 os.makedirs(config_dir, exist_ok=True)
17271 control_file_path = os.path.join(config_dir, f"{run_id}.control")
17272 with open(control_file_path, "w") as f: f.write(final_content)
17273 print(f"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
17274 return os.path.abspath(control_file_path)
17275
17276def resolve_post_decomposition(run_dir: str, num_procs: int, explicit=None,
17277 cluster_cfg: "dict | None" = None) -> dict:
17278 """!
17279 @brief Resolve the postprocessor's MPI decomposition from the run's staged configuration.
17280 @details The postprocessor builds the same grid hierarchy as the solve, from the run's
17281 active case and solver files and its staged grid, so its decomposition is
17282 resolved like the solve's, for its own rank count.
17283 @param[in] run_dir Run directory.
17284 @param[in] num_procs MPI ranks of the post-processing stage.
17285 @param[in] explicit (ni, nj, nk) from the command line, or None.
17286 @param[in] cluster_cfg Parsed cluster.yml, or None.
17287 @return The resolved decomposition (resolve_decomposition()).
17288 """
17289 active = load_active_run_configuration(run_dir)
17290 case_cfg = read_yaml_file(active["case"]) if active.get("case") and os.path.isfile(active["case"]) else {}
17291 solver_cfg = read_yaml_file(active["solver"]) if active.get("solver") and os.path.isfile(active["solver"]) else {}
17292 block_dims = resolve_grid_node_dims(case_cfg, active.get("case") or os.path.join(run_dir, "config", "case.yml"),
17293 os.path.join(run_dir, "inputs", "grid", "grid.run"))
17294 return resolve_decomposition(num_procs, case_cfg.get("grid") or {}, block_dims, solver_cfg,
17295 case_declares_periodic_axis(case_cfg), explicit, cluster_cfg)
17296
17297
17298def generate_post_recipe_file(run_dir: str, run_id: str, post_cfg: dict, source_files: dict, monitor_cfg=None,
17299 decomposition: "dict | None" = None) -> str:
17300 """!
17301 @brief Generates a key=value config file (post.run) for the C post-processor.
17302 @details Translates the structured post-processing YAML into the specific flat
17303 key-value format required by the C executable, including complex,
17304 semicolon-separated pipeline strings.
17305 @param[in] run_dir The path to the main run directory.
17306 @param[in] run_id The unique identifier for the run.
17307 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
17308 @param[in] source_files A dictionary of source files for the header.
17309 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
17310 @param[in] decomposition The postprocessor's resolved decomposition (resolve_post_decomposition()),
17311 or None to let PETSc decide.
17312 @return The absolute path to the generated post.run recipe file.
17313 """
17314 print("[INFO] Generating post-processor recipe file (post.run)...")
17315 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
17316 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
17317 config_dir = get_post_recipe_root(run_dir, post_cfg)
17318 os.makedirs(config_dir, exist_ok=True)
17319 post_recipe_path = os.path.join(config_dir, "post.run")
17320
17321 lines = [generate_header(run_id, source_files)]
17322 c_config = build_post_recipe_config(post_cfg, monitor_cfg)
17323
17324 for key, value in c_config.items():
17325 if value is not None and str(value) != "":
17326 lines.append(f"{key} = {value}")
17327 # The steps themselves are decided when the job starts (prepare_post_job()).
17328 lines.append(f"step_list_file = {get_post_step_list_path(run_dir, post_cfg)}")
17329 if decomposition and decomposition.get("layout") and decomposition["layout"] != (1, 1, 1):
17330 lines.append("da_processors = " + " ".join(str(v) for v in decomposition["layout"]))
17331
17332 with open(post_recipe_path, "w") as f:
17333 f.write("\n".join(lines))
17334 print(f"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
17335 return os.path.abspath(post_recipe_path)
17336
17337def execute_command(command: list, run_dir: str, log_filename: str, monitor_cfg: dict = None):
17338 """!
17339 @brief Executes a command, streaming its output to the console and a log file.
17340 @details ...
17341 If None, the process inherits the parent's environment directly.
17342 @param[in] command Argument passed to `execute_command()`.
17343 @param[in] run_dir Argument passed to `execute_command()`.
17344 @param[in] log_filename Argument passed to `execute_command()`.
17345 @param[in] monitor_cfg Argument passed to `execute_command()`.
17346 """
17347 log_path = resolve_command_log_path(run_dir, log_filename)
17348 os.makedirs(os.path.dirname(log_path), exist_ok=True)
17349
17350 print(f"[INFO] Launching Command...\n > {format_command_for_display(command)}")
17351 print(f" Log file: {os.path.relpath(log_path)}")
17352 print("-" * 60)
17353
17354 # --- Environment Handling ---
17355 popen_kwargs = {
17356 "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT,
17357 "cwd": run_dir, "bufsize": 1, "universal_newlines": True,
17358 "encoding": 'utf-8', "errors": 'replace'
17359 }
17360
17361 run_env = os.environ.copy()
17362 if monitor_cfg:
17363 verbosity = monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()
17364 run_env['LOG_LEVEL'] = verbosity
17365 print(f"[INFO] Log level: {verbosity}")
17366 # The launched executable's own PETSc first, as job scripts do (petsc_library_path_lines()).
17367 for token in command:
17368 library_dir = executable_petsc_library_dir(str(token)) if os.path.isabs(str(token)) else None
17369 if library_dir:
17370 run_env["LD_LIBRARY_PATH"] = library_dir + (":" + run_env["LD_LIBRARY_PATH"]
17371 if run_env.get("LD_LIBRARY_PATH") else "")
17372 break
17373 popen_kwargs['env'] = run_env
17374
17375 print("-" * 60)
17376 try:
17377 # Pass the constructed keyword arguments dictionary to Popen
17378 process = subprocess.Popen(command, **popen_kwargs)
17379
17380 with open(log_path, "w") as log_file:
17381 for line in process.stdout:
17382 sys.stdout.write(line)
17383 log_file.write(line)
17384 process.wait()
17385 return_code = process.returncode
17386 print("-" * 60)
17387 if return_code == 0:
17388 print(f"[SUCCESS] Execution finished successfully.")
17389 else:
17390 print(f"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
17391 sys.exit(return_code)
17392 except FileNotFoundError:
17393 print(f"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
17394 print(" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
17395 sys.exit(1)
17396 except Exception as e:
17397 print(f"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
17398 sys.exit(1)
17399
17400
17401def format_command_for_display(command: list) -> str:
17402 """!
17403 @brief Render a shell-safe command string for console and log output.
17404 @param[in] command Argument passed to `format_command_for_display()`.
17405 @return Value returned by `format_command_for_display()`.
17406 """
17407 return " ".join(shlex.quote(str(part)) for part in command)
17408
17409
17410def resolve_command_log_path(run_dir: str, log_filename: str) -> str:
17411 """!
17412 @brief Resolve a command log filename relative to the run directory.
17413 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
17414 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
17415 @return Value returned by `resolve_command_log_path()`.
17416 """
17417 if os.path.dirname(log_filename):
17418 return os.path.join(run_dir, log_filename)
17419 return os.path.join(run_dir, "logs", log_filename)
17420
17421
17422class CommandExecutionError(RuntimeError):
17423 """!
17424 @brief Raised when an external command exits unsuccessfully.
17425 """
17426
17427 def __init__(self, command: list, returncode: int, details: str = None):
17428 """!
17429 @brief Initialize a command execution error.
17430 @param[in] command Argument passed to `__init__()`.
17431 @param[in] returncode Argument passed to `__init__()`.
17432 @param[in] details Argument passed to `__init__()`.
17433 """
17434 self.command = command
17435 self.returncode = returncode
17436 self.details = details
17437 detail_suffix = f": {details}" if details else ""
17438 super().__init__(
17439 f"Command failed with exit code {returncode}: {format_command_for_display(command)}{detail_suffix}"
17440 )
17441
17442
17443class PlotDependencyError(RuntimeError):
17444 """!
17445 @brief Raised when plot.gen reports a missing optional dependency.
17446 """
17447
17448
17449def _run_captured_command(command: list, run_dir: str) -> subprocess.CompletedProcess:
17450 """!
17451 @brief Run a command and capture combined stdout/stderr details for later inspection.
17452 @param[in] command Argument passed to `_run_captured_command()`.
17453 @param[in] run_dir Argument passed to `_run_captured_command()`.
17454 @return Value returned by `_run_captured_command()`.
17455 """
17456 try:
17457 return subprocess.run(
17458 command,
17459 cwd=run_dir,
17460 text=True,
17461 capture_output=True,
17462 check=False,
17463 encoding="utf-8",
17464 errors="replace",
17465 )
17466 except FileNotFoundError as exc:
17467 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
17468
17469
17470def _require_successful_command(command: list, result: subprocess.CompletedProcess):
17471 """!
17472 @brief Raise `CommandExecutionError` when a captured command failed.
17473 @param[in] command Argument passed to `_require_successful_command()`.
17474 @param[in] result Argument passed to `_require_successful_command()`.
17475 """
17476 if result.returncode == 0:
17477 return
17478 details = (result.stderr or result.stdout).strip()
17479 raise CommandExecutionError(command, result.returncode, details or None)
17480
17481
17482def _capture_command_stdout(command: list, run_dir: str) -> str:
17483 """!
17484 @brief Run a command, require success, and return stripped stdout text.
17485 @param[in] command Argument passed to `_capture_command_stdout()`.
17486 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
17487 @return Value returned by `_capture_command_stdout()`.
17488 """
17489 result = _run_captured_command(command, run_dir)
17490 _require_successful_command(command, result)
17491 return result.stdout.strip()
17492
17493
17494def _stream_command_to_console_and_log(command: list, run_dir: str, log_file):
17495 """!
17496 @brief Stream command output to stdout and an already-open log file.
17497 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
17498 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
17499 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
17500 """
17501 display = format_command_for_display(command)
17502 print(f"[INFO] Running: {display}")
17503 log_file.write(f"$ {display}\n")
17504 log_file.flush()
17505
17506 popen_kwargs = {
17507 "stdout": subprocess.PIPE,
17508 "stderr": subprocess.STDOUT,
17509 "cwd": run_dir,
17510 "bufsize": 1,
17511 "universal_newlines": True,
17512 "encoding": "utf-8",
17513 "errors": "replace",
17514 }
17515
17516 try:
17517 process = subprocess.Popen(command, **popen_kwargs)
17518 except FileNotFoundError as exc:
17519 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
17520
17521 with process:
17522 for line in process.stdout:
17523 sys.stdout.write(line)
17524 log_file.write(line)
17525 return_code = process.wait()
17526 log_file.write("\n")
17527 log_file.flush()
17528 if return_code != 0:
17529 raise CommandExecutionError(command, return_code)
17530
17531
17532def _get_git_head_state(run_dir: str) -> dict:
17533 """!
17534 @brief Capture the current git HEAD branch name and commit hash.
17535 @param[in] run_dir Argument passed to `_get_git_head_state()`.
17536 @return Value returned by `_get_git_head_state()`.
17537 """
17538 head_commit = _capture_command_stdout(["git", "rev-parse", "--verify", "HEAD"], run_dir)
17539 branch_result = _run_captured_command(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], run_dir)
17540 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
17541 return {"branch": branch_name, "commit": head_commit}
17542
17543
17544def _get_local_branches_with_upstreams(run_dir: str) -> "list[tuple[str, str | None]]":
17545 """!
17546 @brief Return local branch names plus their configured upstreams.
17547 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
17548 @return Value returned by `_get_local_branches_with_upstreams()`.
17549 """
17550 output = _capture_command_stdout(
17551 ["git", "for-each-ref", "--sort=refname", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
17552 run_dir,
17553 )
17554 branches = []
17555 for line in output.splitlines():
17556 if not line.strip():
17557 continue
17558 branch_name, _, upstream_name = line.partition("\t")
17559 branches.append((branch_name, upstream_name or None))
17560 return branches
17561
17562
17563def _working_tree_has_tracked_changes(run_dir: str) -> bool:
17564 """!
17565 @brief Return `True` when the repository has staged or unstaged tracked changes.
17566 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
17567 @return Value returned by `_working_tree_has_tracked_changes()`.
17568 """
17569 command = ["git", "status", "--porcelain", "--untracked-files=no"]
17570 result = _run_captured_command(command, run_dir)
17571 _require_successful_command(command, result)
17572 return bool(result.stdout.strip())
17573
17574
17575def _attempt_pull_cleanup(run_dir: str, rebase: bool, log_file):
17576 """!
17577 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
17578 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
17579 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
17580 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
17581 """
17582 cleanup_command = ["git", "rebase", "--abort"] if rebase else ["git", "merge", "--abort"]
17583 result = _run_captured_command(cleanup_command, run_dir)
17584 if result.returncode == 0:
17585 print(f"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
17586 log_file.write(f"$ {format_command_for_display(cleanup_command)}\n")
17587 if result.stdout:
17588 sys.stdout.write(result.stdout)
17589 log_file.write(result.stdout)
17590 if result.stderr:
17591 sys.stderr.write(result.stderr)
17592 log_file.write(result.stderr)
17593 log_file.write("\n")
17594 log_file.flush()
17595 return
17596
17597 details = (result.stderr or result.stdout).strip()
17598 if details:
17599 message = (
17600 f"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
17601 )
17602 print(message, file=sys.stderr)
17603 log_file.write(message + "\n")
17604 log_file.flush()
17605
17606
17607def _restore_git_head(run_dir: str, original_head: dict, log_file):
17608 """!
17609 @brief Restore the repository back to the branch or detached commit it started on.
17610 @param[in] run_dir Argument passed to `_restore_git_head()`.
17611 @param[in] original_head Argument passed to `_restore_git_head()`.
17612 @param[in] log_file Argument passed to `_restore_git_head()`.
17613 """
17614 current_state = _get_git_head_state(run_dir)
17615 if original_head["branch"]:
17616 if current_state["branch"] == original_head["branch"]:
17617 return
17618 _stream_command_to_console_and_log(["git", "checkout", original_head["branch"]], run_dir, log_file)
17619 return
17620
17621 if current_state["branch"] is None and current_state["commit"] == original_head["commit"]:
17622 return
17623 _stream_command_to_console_and_log(["git", "checkout", "--detach", original_head["commit"]], run_dir, log_file)
17624
17625
17626def _refuse_detached_source_checkout(source_project_root: str, action: str) -> None:
17627 """!
17628 @brief Refuse a branch pull when the source checkout is pinned to a detached commit.
17629
17630 @details `versions install` and `versions activate` leave the checkout detached on
17631 the version they built. A branch pull there either fails inside Git or, in
17632 the multi-branch path, updates the branches and then restores the detached
17633 commit, so the active code never changes while the command reports success.
17634 A directory that is not a Git checkout is left for the Git command to report.
17635 @param[in] source_project_root PICurv source checkout to inspect.
17636 @param[in] action User-facing command name.
17637 @throws ValueError when HEAD is detached.
17638 """
17639 branch = _run_captured_command(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], source_project_root)
17640 if branch.returncode == 0:
17641 return
17642 commit = _run_captured_command(["git", "rev-parse", "--short=12", "--verify", "HEAD"], source_project_root)
17643 if commit.returncode != 0:
17644 return
17645 raise ValueError(
17646 f"{action}: the source checkout at {source_project_root} is pinned to commit "
17647 f"{commit.stdout.strip()}, not a branch, so a branch pull would not change the code "
17648 "that runs.\n"
17649 " Install a newer version with 'picurv versions install <tag-or-commit>', "
17650 "or check out a branch (for example 'git checkout main') to follow it."
17651 )
17652
17653
17654def pull_all_source_branches(run_dir: str, log_filename: str, rebase: bool = True):
17655 """!
17656 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
17657 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
17658 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
17659 @param[in] rebase Argument passed to `pull_all_source_branches()`.
17660 """
17661 log_path = resolve_command_log_path(run_dir, log_filename)
17662 os.makedirs(os.path.dirname(log_path), exist_ok=True)
17663
17664 print("\n" + "="*23 + " PULL SOURCE STAGE " + "="*22)
17665 print("[INFO] Refreshing all local source branches that track an upstream.")
17666 print(f" Log file: {os.path.relpath(log_path)}")
17667 print("-" * 60)
17668
17669 try:
17670 original_head = _get_git_head_state(run_dir)
17672 raise RuntimeError(
17673 "Multi-branch pull requires a clean tracked working tree in the source repository. "
17674 "Commit or stash those changes first, or rerun with --current-branch-only."
17675 )
17676 branches = _get_local_branches_with_upstreams(run_dir)
17677 except (CommandExecutionError, RuntimeError) as exc:
17678 print(f"[FATAL] {exc}", file=sys.stderr)
17679 sys.exit(getattr(exc, "returncode", 1))
17680
17681 if not branches:
17682 print("[FATAL] No local branches were found in the source repository.", file=sys.stderr)
17683 sys.exit(1)
17684
17685 if original_head["branch"]:
17686 branches = [item for item in branches if item[0] != original_head["branch"]] + [
17687 item for item in branches if item[0] == original_head["branch"]
17688 ]
17689
17690 skipped_branches = []
17691 current_operation = None
17692 pull_error = None
17693 restore_error = None
17694
17695 with open(log_path, "w", encoding="utf-8") as log_file:
17696 log_file.write(f"# PICurv pull-source all-branch sync\n")
17697 log_file.write(f"# repository: {os.path.abspath(run_dir)}\n")
17698 log_file.write(f"# started: {datetime.now().isoformat()}\n")
17699 log_file.write(
17700 f"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
17701 )
17702
17703 try:
17704 for branch_name, upstream_name in branches:
17705 if not upstream_name:
17706 warning = f"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
17707 print(warning, file=sys.stderr)
17708 log_file.write(warning + "\n")
17709 skipped_branches.append(branch_name)
17710 continue
17711
17712 print(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
17713 log_file.write(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
17714
17715 current_operation = f"checkout:{branch_name}"
17716 _stream_command_to_console_and_log(["git", "checkout", branch_name], run_dir, log_file)
17717
17718 pull_command = ["git", "pull"]
17719 if rebase:
17720 pull_command.append("--rebase")
17721 current_operation = f"pull:{branch_name}"
17722 _stream_command_to_console_and_log(pull_command, run_dir, log_file)
17723 current_operation = None
17724 except CommandExecutionError as exc:
17725 pull_error = exc
17726 if current_operation and current_operation.startswith("pull:"):
17727 _attempt_pull_cleanup(run_dir, rebase, log_file)
17728 finally:
17729 try:
17730 _restore_git_head(run_dir, original_head, log_file)
17731 except CommandExecutionError as exc:
17732 restore_error = exc
17733
17734 print("-" * 60)
17735 if pull_error:
17736 if restore_error:
17737 print(
17738 f"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
17739 f"Check log: {os.path.relpath(log_path)}",
17740 file=sys.stderr,
17741 )
17742 sys.exit(restore_error.returncode)
17743 print(
17744 f"[FATAL] Multi-branch pull failed. Original branch restored. "
17745 f"Check log: {os.path.relpath(log_path)}",
17746 file=sys.stderr,
17747 )
17748 sys.exit(pull_error.returncode)
17749
17750 if restore_error:
17751 print(
17752 f"[FATAL] Branch updates completed, but the original branch could not be restored. "
17753 f"Check log: {os.path.relpath(log_path)}",
17754 file=sys.stderr,
17755 )
17756 sys.exit(restore_error.returncode)
17757
17758 if skipped_branches:
17759 print(f"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
17760 print("[SUCCESS] All local tracking branches are up to date.")
17761
17762def auto_identify_run_inputs(config_dir: str):
17763 """!
17764 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
17765 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
17766 @return Value returned by `auto_identify_run_inputs()`.
17767 """
17768 run_dir = os.path.dirname(os.path.abspath(config_dir))
17769 active = load_active_run_configuration(run_dir)
17770 if active:
17771 case_path = active.get("case")
17772 monitor_path = active.get("monitor")
17773 solver_control_path = active.get("control")
17774 if all(path and os.path.isfile(path) for path in (case_path, monitor_path, solver_control_path)):
17775 return case_path, monitor_path, solver_control_path
17776 all_yml_files = glob.glob(os.path.join(config_dir, "*.yml"))
17777 case_path, monitor_path = None, None
17778 for f_path in all_yml_files:
17779 try:
17780 content = read_yaml_file(f_path)
17781 if not isinstance(content, dict):
17782 continue
17783 if 'models' in content and 'boundary_conditions' in content:
17784 case_path = f_path
17785 elif 'io' in content and 'logging' in content:
17786 monitor_path = f_path
17787 except Exception as e:
17788 print(f"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
17789 try:
17790 solver_control_path = glob.glob(os.path.join(config_dir, "*.control"))[0]
17791 except IndexError:
17792 solver_control_path = None
17793 return case_path, monitor_path, solver_control_path
17794
17795def resolve_post_source_directory(run_dir: str, monitor_cfg: dict, post_cfg: dict, strict: bool = True) -> str:
17796 """!
17797 @brief Resolve post source directory token and optionally enforce existence.
17798 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
17799 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
17800 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
17801 @param[in] strict Argument passed to `resolve_post_source_directory()`.
17802 @return Value returned by `resolve_post_source_directory()`.
17803 """
17804 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
17805 source_dir_template = get_post_source_directory_template(post_cfg)
17806 if source_dir_template == '<solver_output_dir>':
17807 resolved_source_dir = solver_output_dir_abs
17808 print(f"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
17809 else:
17810 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
17811 print(f"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
17812
17813 if strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
17814 print(
17815 f"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
17816 file=sys.stderr
17817 )
17818 sys.exit(1)
17819 if not strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
17820 print("[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
17821 return resolved_source_dir
17822
17824 script_path: str,
17825 job_name: str,
17826 cluster_cfg: dict,
17827 array_spec: str,
17828 case_index_tsv: str,
17829 stage: str,
17830 solver_exe: str,
17831 post_exe: str,
17832 stdout_path: str,
17833 stderr_path: str,
17834 expected_version_line: "str | None" = None,
17835):
17836 """!
17837 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
17838 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
17839 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
17840 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
17841 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
17842 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
17843 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
17844 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
17845 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
17846 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
17847 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
17848 @param[in] expected_version_line `--version` output the stage's executable reported
17849 at staging; checked before launch in every task.
17850 @return Value returned by `render_slurm_array_stage_script()`.
17851 """
17852 effective_cluster_cfg = cluster_cfg
17853 resources = effective_cluster_cfg.get("resources", {})
17854 notifications = effective_cluster_cfg.get("notifications", {}) or {}
17855 execution = effective_cluster_cfg.get("execution", {}) or {}
17856 module_setup = execution.get("module_setup", []) or []
17857 extra_sbatch = execution.get("extra_sbatch")
17858
17859 lines = [
17860 "#!/bin/bash",
17861 f"#SBATCH --job-name={job_name}",
17862 f"#SBATCH --nodes={resources['nodes']}",
17863 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
17864 f"#SBATCH --mem={resources['mem']}",
17865 f"#SBATCH --time={resources['time']}",
17866 f"#SBATCH --output={stdout_path}",
17867 f"#SBATCH --error={stderr_path}",
17868 f"#SBATCH --account={resources['account']}",
17869 f"#SBATCH --array={array_spec}",
17870 ]
17871 partition = resources.get("partition")
17872 if partition:
17873 lines.append(f"#SBATCH --partition={partition}")
17874 mail_user = notifications.get("mail_user")
17875 mail_type = notifications.get("mail_type")
17876 if mail_user:
17877 lines.append(f"#SBATCH --mail-user={mail_user}")
17878 if mail_type:
17879 lines.append(f"#SBATCH --mail-type={mail_type}")
17880 if isinstance(extra_sbatch, dict):
17881 for key, value in extra_sbatch.items():
17882 flag = str(key)
17883 if not flag.startswith("--"):
17884 flag = f"--{flag}"
17885 if isinstance(value, bool):
17886 if value:
17887 lines.append(f"#SBATCH {flag}")
17888 elif value is not None:
17889 lines.append(f"#SBATCH {flag}={value}")
17890 elif isinstance(extra_sbatch, list):
17891 for token in extra_sbatch:
17892 lines.append(f"#SBATCH {token}")
17893
17894 lines.extend([
17895 "",
17896 "set -euo pipefail",
17897 "",
17898 f'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
17899 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
17900 'if [ -z "$LINE" ]; then',
17901 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
17902 ' exit 1',
17903 "fi",
17904 "IFS=$'\\t' read -r CASE_INDEX CASE_ID RUN_DIR CONTROL_FILE POST_RECIPE_FILE LOG_LEVEL POST_PREFIX SOLVE_DIAGNOSTIC_ARGS POST_DIAGNOSTIC_ARGS <<< \"$LINE\"",
17905 'cd "$RUN_DIR"',
17906 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
17907 ])
17908
17909 if stage == "solve":
17910 walltime_guard_exports = build_walltime_guard_exports(effective_cluster_cfg)
17911 for key, value in walltime_guard_exports.items():
17912 lines.append(f"export {key}={value}")
17913
17914 lines.append('export LOG_LEVEL="${LOG_LEVEL}"')
17915
17916 for setup_line in module_setup:
17917 lines.append(str(setup_line))
17918
17919 stage_exe = solver_exe if stage == "solve" else post_exe
17920 # A pinned `$RUN_DIR/...` copy is the installation's binary, so its PETSc is the installation's.
17922 stage_exe if not stage_exe.startswith("$") else
17923 resolve_runtime_executable("simulator" if stage == "solve" else "postprocessor"))))
17925 stage_exe, expected_version_line, pinned=stage_exe.startswith("$RUN_DIR/")
17926 ))
17927
17928 if stage == "solve":
17930 effective_cluster_cfg,
17931 solver_exe,
17932 ["-control_file", "$CONTROL_FILE"]
17933 )
17934 else:
17936 effective_cluster_cfg,
17937 post_exe,
17938 ["-control_file", "$CONTROL_FILE", "-postprocessing_config_file", "$POST_RECIPE_FILE"],
17939 force_num_procs=get_cluster_total_tasks(effective_cluster_cfg),
17940 )
17941
17942 # Keep shell variables unresolved inside sbatch script.
17943 def _token(tok: str) -> str:
17944 """!
17945 @brief Preserve shell-variable tokens while safely quoting literal command arguments for an sbatch script.
17946 @param[in] tok Argument passed to `_token()`.
17947 @return Value returned by `_token()`.
17948 """
17949 if tok.startswith("$"):
17950 return tok
17951 return shlex.quote(str(tok))
17952
17953 diag_var = "${SOLVE_DIAGNOSTIC_ARGS}" if stage == "solve" else "${POST_DIAGNOSTIC_ARGS}"
17954 command_text = " ".join(_token(t) for t in cmd)
17955 executable_token = _token(solver_exe if stage == "solve" else post_exe)
17956 # Diagnostic args must be executable args, not launcher args. Insert them
17957 # immediately before the executable's normal control/recipe options.
17958 if executable_token and command_text.count(executable_token) == 1:
17959 command_text = command_text.replace(f"{executable_token} ", f"{executable_token} {diag_var} ", 1)
17960 if stage == "post":
17961 # A member's post runs under the same lock wrapper as a standalone run, which
17962 # plans the steps as the task starts, so a continued study skips every member
17963 # step whose output is up to date.
17964 lock_paths = get_post_lock_paths("$RUN_DIR")
17965 lines.append(
17966 f"{shlex.quote(sys.executable)} \"{lock_paths['wrapper_path']}\" "
17967 f"--lock-file \"{lock_paths['lock_file']}\" "
17968 f"--metadata-file \"{lock_paths['metadata_file']}\" "
17969 f"--run-dir \"$RUN_DIR\" --post-recipe \"$RUN_DIR/config/post.yml\" "
17970 f"--postprocessor {executable_token} -- {command_text}"
17971 )
17972 # Study members are ordinary root runs, but their post recipes may request the
17973 # same run-local physical-time PVD product and reducers as standalone runs.
17974 # Keep this serial and downstream of the MPI postprocessor.
17975 lines.append(
17976 f"{shlex.quote(sys.executable)} -c {shlex.quote(_post_finalize_python_source())} "
17977 '"$RUN_DIR" "$RUN_DIR/config/post.yml" reducers'
17978 )
17979 else:
17980 lines.append(f"exec {command_text}")
17981
17982 os.makedirs(os.path.dirname(script_path), exist_ok=True)
17983 with open(script_path, "w") as f:
17984 f.write("\n".join(lines) + "\n")
17985 os.chmod(script_path, 0o755)
17986
17987
17989 script_path: str,
17990 job_name: str,
17991 cluster_cfg: dict,
17992 study_dir: str,
17993 picurv_path: str,
17994):
17995 """!
17996 @brief Generate a single-node sbatch script that runs metrics aggregation.
17997 @param[in] script_path Path to write the sbatch script.
17998 @param[in] job_name Slurm job name.
17999 @param[in] cluster_cfg Parsed cluster YAML dictionary.
18000 @param[in] study_dir Absolute path to the study directory.
18001 @param[in] picurv_path Absolute path to the picurv script.
18002 """
18003 resources = cluster_cfg.get("resources", {})
18004 notifications = cluster_cfg.get("notifications", {}) or {}
18005 execution = cluster_cfg.get("execution", {}) or {}
18006 module_setup = execution.get("module_setup", []) or []
18007
18008 scheduler_dir = os.path.join(study_dir, "scheduler")
18009 lines = [
18010 "#!/bin/bash",
18011 f"#SBATCH --job-name={job_name}",
18012 "#SBATCH --nodes=1",
18013 "#SBATCH --ntasks-per-node=1",
18014 "#SBATCH --mem=4G",
18015 "#SBATCH --time=00:10:00",
18016 f"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
18017 f"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
18018 f"#SBATCH --account={resources['account']}",
18019 ]
18020 partition = resources.get("partition")
18021 if partition:
18022 lines.append(f"#SBATCH --partition={partition}")
18023 mail_user = notifications.get("mail_user")
18024 mail_type = notifications.get("mail_type")
18025 if mail_user:
18026 lines.append(f"#SBATCH --mail-user={mail_user}")
18027 if mail_type:
18028 lines.append(f"#SBATCH --mail-type={mail_type}")
18029
18030 lines.extend([
18031 "",
18032 "set -euo pipefail",
18033 'echo "[$(date)] Running metrics aggregation"',
18034 "",
18035 ])
18036 for setup_line in module_setup:
18037 lines.append(str(setup_line))
18038
18039 lines.append(
18040 f"exec {shlex.quote(picurv_path)} sweep --reaggregate"
18041 f" --study-dir {shlex.quote(study_dir)}"
18042 )
18043
18044 os.makedirs(os.path.dirname(script_path), exist_ok=True)
18045 with open(script_path, "w") as f:
18046 f.write("\n".join(lines) + "\n")
18047 os.chmod(script_path, 0o755)
18048
18049
18050def reduce_metric_values(values, reduction: str):
18051 """!
18052 @brief Reduce a metric series to one scalar according to the requested reducer.
18053 @param[in] values Sequence of numeric values.
18054 @param[in] reduction Reduction keyword.
18055 @return Value returned by `reduce_metric_values()`.
18056 """
18057 if not values:
18058 return None
18059 np = require_numpy()
18060 reduction = str(reduction).lower()
18061 if reduction == "mean":
18062 return float(np.mean(values))
18063 if reduction == "min":
18064 return float(np.min(values))
18065 if reduction == "max":
18066 return float(np.max(values))
18067 if reduction == "p95":
18068 return float(np.percentile(values, 95.0))
18069 return float(values[-1])
18070
18071
18072def extract_metric_from_csv(case_dir: str, spec: dict):
18073 """!
18074 @brief Extract a scalar metric from a CSV source.
18075 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
18076 @param[in] spec Argument passed to `extract_metric_from_csv()`.
18077 @return Value returned by `extract_metric_from_csv()`.
18078 """
18079 file_glob = spec.get("file_glob", "**/*_msd.csv")
18080 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
18081 if not candidates:
18082 return None
18083 csv_path = candidates[0]
18084 rows = []
18085 with open(csv_path, "r", newline="") as f:
18086 reader = csv.DictReader(f)
18087 if reader.fieldnames:
18088 for row in reader:
18089 rows.append(row)
18090 if not rows:
18091 return None
18092 column = spec.get("column")
18093 numerator_column = spec.get("numerator_column")
18094 denominator_column = spec.get("denominator_column")
18095 denominator_floor = float(spec.get("denominator_floor", 0.0) or 0.0)
18096 if not column and not numerator_column:
18097 for name in reversed(reader.fieldnames):
18098 if name and name.lower() not in {"step", "time", "timestep", "physical_time"}:
18099 column = name
18100 break
18101 if not column and not numerator_column:
18102 return None
18103 values = []
18104 for row in rows:
18105 try:
18106 if numerator_column:
18107 numerator = float(row[numerator_column])
18108 denominator = float(row[denominator_column])
18109 denominator = max(denominator_floor, denominator)
18110 if denominator == 0.0:
18111 continue
18112 values.append(numerator / denominator)
18113 else:
18114 values.append(float(row[column]))
18115 except Exception:
18116 continue
18117 else:
18118 return None
18119 return reduce_metric_values(values, spec.get("reduction", "last"))
18120
18121
18122def extract_metric_from_log(case_dir: str, spec: dict):
18123 """!
18124 @brief Extract a scalar metric from a log file using regex.
18125 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
18126 @param[in] spec Argument passed to `extract_metric_from_log()`.
18127 @return Value returned by `extract_metric_from_log()`.
18128 """
18129 file_glob = spec.get("file_glob", "logs/*.log")
18130 regex = spec.get("regex")
18131 if not regex:
18132 return None
18133 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
18134 if not candidates:
18135 return None
18136 pattern = re.compile(regex)
18137 values = []
18138 for path in candidates:
18139 try:
18140 with open(path, "r", encoding="utf-8", errors="replace") as f:
18141 for line in f:
18142 m = pattern.search(line)
18143 if m:
18144 try:
18145 values.append(float(m.group(1)))
18146 except Exception:
18147 pass
18148 except OSError:
18149 continue
18150 return reduce_metric_values(values, spec.get("reduction", "last"))
18151
18152
18154 """!
18155 @brief Normalize study metric definitions to a common dictionary form.
18156 @param[in] metric Argument passed to `normalize_metric_spec()`.
18157 @return Value returned by `normalize_metric_spec()`.
18158 """
18159 if isinstance(metric, str):
18160 if metric.lower() in {"msd", "msd_final"}:
18161 return {
18162 "name": "msd_final",
18163 "source": "statistics_csv",
18164 "file_glob": "**/*_msd.csv",
18165 # Named, not left to the last-column fallback, which is frac_3sigma_pct.
18166 "column": "MSD_total",
18167 "reduction": "last",
18168 }
18169 return {"name": metric, "source": "log_regex", "regex": metric}
18170 return dict(metric)
18171
18172def _read_previous_metric_rows(results_dir: str) -> dict:
18173 """!
18174 @brief Read the metrics table an earlier aggregation wrote, keyed by case id.
18175 @param[in] results_dir Study analysis directory holding metrics_table.csv.
18176 @return Mapping of case id to its previously recorded row, empty when absent.
18177 """
18178 path = os.path.join(results_dir, "metrics_table.csv")
18179 if not os.path.isfile(path):
18180 return {}
18181 try:
18182 with open(path, "r", encoding="utf-8", newline="") as stream:
18183 return {
18184 row["case_id"]: row for row in csv.DictReader(stream) if row.get("case_id")
18185 }
18186 except (OSError, ValueError):
18187 return {}
18188
18189
18190def aggregate_study_metrics(study_cfg: dict, cases: list, results_dir: str) -> str:
18191 """!
18192 @brief Collect metric values from generated case directories into one CSV.
18193 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
18194 @param[in] cases Argument passed to `aggregate_study_metrics()`.
18195 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
18196 @return Value returned by `aggregate_study_metrics()`.
18197 """
18198 metrics = study_cfg.get("metrics", [])
18199 if not metrics:
18200 metrics = ["msd_final"]
18201 normalized_specs = [normalize_metric_spec(m) for m in metrics]
18202
18203 # A member whose payload was archived cannot be re-measured. Its previously
18204 # aggregated values are the correct answer for it, so they are carried forward
18205 # rather than overwritten with blanks or the whole table refused.
18206 preserved = _read_previous_metric_rows(results_dir)
18207 rows = []
18208 for case in cases:
18209 row = {"case_id": case["case_id"]}
18210 flat_parameters = flatten_study_parameters(case.get("parameters", {}))
18211 for p_key, p_val in flat_parameters.items():
18212 row[p_key] = p_val
18213 if is_artifact_cold(case["run_dir"]):
18214 retained = preserved.get(case["case_id"])
18215 if retained:
18216 for spec in normalized_specs:
18217 name = spec.get("name", "metric")
18218 row[name] = retained.get(name)
18219 row["_source"] = "retained"
18220 rows.append(row)
18221 continue
18222 print(
18223 f"[WARNING] {case['case_id']} is in cold storage and no previous metrics "
18224 "row was found; its values are reported as unavailable.",
18225 file=sys.stderr,
18226 )
18227 for spec in normalized_specs:
18228 row[spec.get("name", "metric")] = None
18229 rows.append(row)
18230 continue
18231 for spec in normalized_specs:
18232 name = spec.get("name", "metric")
18233 source = str(spec.get("source", "")).lower()
18234 if source in METRIC_SOURCE_KINDS[:2]:
18235 value = extract_metric_from_csv(case["run_dir"], spec)
18236 elif source in METRIC_SOURCE_KINDS[2:]:
18237 value = extract_metric_from_log(case["run_dir"], spec)
18238 else:
18239 value = None
18240
18241 normalize_key = spec.get("normalize_by_parameter")
18242 if value is not None and normalize_key:
18243 denom = flat_parameters.get(normalize_key)
18244 try:
18245 denom = float(denom)
18246 except Exception:
18247 denom = None
18248 if denom not in (None, 0.0):
18249 value = float(value) / denom
18250 else:
18251 value = None
18252
18253 row[name] = value
18254 rows.append(row)
18255
18256 if not rows:
18257 return None
18258
18259 all_keys = []
18260 seen = set()
18261 for row in rows:
18262 for k in row.keys():
18263 if k not in seen:
18264 seen.add(k)
18265 all_keys.append(k)
18266
18267 os.makedirs(results_dir, exist_ok=True)
18268 out_csv = os.path.join(results_dir, "metrics_table.csv")
18269 with open(out_csv, "w", newline="") as f:
18270 writer = csv.DictWriter(f, fieldnames=all_keys)
18271 writer.writeheader()
18272 writer.writerows(rows)
18273 metric_names = [spec.get("name", "metric") for spec in normalized_specs]
18274 if any(row.get(name) is not None for row in rows for name in metric_names):
18275 print(f"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
18276 else:
18277 # At submission no member has run; the metrics job fills the table later.
18278 print(f"[INFO] Initialized metrics table (no member has produced metrics yet): "
18279 f"{os.path.relpath(out_csv)}")
18280 return out_csv
18281
18282_STUDY_REPORT_COLORS = (
18283 "#0072B2", "#D55E00", "#009E73", "#CC79A7",
18284 "#E69F00", "#56B4E9", "#332288", "#999999",
18285)
18286
18287
18288def _study_parameter_label(key: str) -> str:
18289 """!
18290 @brief Return a concise report label for a study parameter path.
18291 @param[in] key Dotted study parameter path.
18292 @return Report-facing axis or legend label.
18293 """
18294 exact = {
18295 "case.run_control.dt_physical": "Physical timestep, Δt",
18296 "case.models.physics.particles.count": "Particle count",
18297 "solver.operation_mode.uniform_flow.u": "Uniform-flow velocity, u",
18298 "case.grid.programmatic_settings.im": "Grid nodes in i, Nᵢ",
18299 "case.grid.programmatic_settings.jm": "Grid nodes in j, Nⱼ",
18300 "case.grid.programmatic_settings.km": "Grid nodes in k, Nₖ",
18301 }
18302 return exact.get(key, _humanize_plot_identifier(key))
18303
18304
18305def _study_metric_label(study_cfg: dict, metric: str) -> str:
18306 """!
18307 @brief Resolve an optional configured metric label or humanize its name.
18308 @param[in] study_cfg Parsed study configuration.
18309 @param[in] metric Metric column name.
18310 @return Report-facing metric label.
18311 """
18312 for raw_spec in study_cfg.get("metrics", []) or []:
18313 spec = normalize_metric_spec(raw_spec)
18314 if spec.get("name") != metric:
18315 continue
18316 label = spec.get("plot_label") or spec.get("label")
18317 units = spec.get("units")
18318 base = str(label) if label else _humanize_plot_identifier(metric)
18319 return f"{base} ({units})" if units else base
18320 return _humanize_plot_identifier(metric)
18321
18322
18323def _numeric_study_column(rows: list, key: str) -> "list | None":
18324 """!
18325 @brief Parse one complete, finite numeric study-table column.
18326 @param[in] rows Metrics-table rows.
18327 @param[in] key Column name.
18328 @return Numeric values, or None when the column is incomplete or nonnumeric.
18329 """
18330 values = []
18331 for row in rows:
18332 try:
18333 value = float(row[key])
18334 except (KeyError, TypeError, ValueError):
18335 return None
18336 if not math.isfinite(value):
18337 return None
18338 values.append(value)
18339 return values
18340
18341
18342def _infer_study_plot_axis(study_cfg: dict, rows: list) -> "dict | None":
18343 """!
18344 @brief Infer the scientifically meaningful independent variable of a study.
18345 @param[in] study_cfg Parsed study configuration.
18346 @param[in] rows Metrics-table rows.
18347 @return Axis metadata, or None when no numeric independent variable exists.
18348 """
18349 params = [key for key in get_study_parameter_keys(study_cfg) if rows and key in rows[0]]
18350 if not params or not rows:
18351 return None
18352
18353 grid_keys = (
18354 "case.grid.programmatic_settings.im",
18355 "case.grid.programmatic_settings.jm",
18356 "case.grid.programmatic_settings.km",
18357 )
18358 if study_cfg.get("study_type") == "grid_independence" and all(key in params for key in grid_keys):
18359 columns = [_numeric_study_column(rows, key) for key in grid_keys]
18360 if all(column is not None for column in columns):
18361 values = [
18362 (columns[0][index] * columns[1][index] * columns[2][index]) ** (1.0 / 3.0)
18363 for index in range(len(rows))
18364 ]
18365 return {
18366 "key": "characteristic_grid_resolution",
18367 "label": "Characteristic grid resolution, (NᵢNⱼNₖ)¹⁄³",
18368 "slug": "characteristic_grid_resolution",
18369 "values": values,
18370 "contributors": set(grid_keys),
18371 }
18372
18373 preferred = []
18374 if study_cfg.get("study_type") == "timestep_independence":
18375 preferred = [key for key in params if key.endswith(".dt_physical")]
18376 numeric = []
18377 for key in preferred + [key for key in params if key not in preferred]:
18378 values = _numeric_study_column(rows, key)
18379 if values is not None:
18380 numeric.append((key, values, len(set(values))))
18381 if not numeric:
18382 return None
18383 varied = [candidate for candidate in numeric if candidate[2] > 1]
18384 key, values, _unique = (varied or numeric)[0]
18385 return {
18386 "key": key,
18387 "label": _study_parameter_label(key),
18388 "slug": re.sub(r"[^A-Za-z0-9_.-]+", "_", key),
18389 "values": values,
18390 "contributors": {key},
18391 }
18392
18393
18394def infer_plot_x_axis(study_cfg: dict, rows: list):
18395 """!
18396 @brief Infer x-axis key/values for study plots.
18397 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
18398 @param[in] rows Argument passed to `infer_plot_x_axis()`.
18399 @return Value returned by `infer_plot_x_axis()`.
18400 """
18401 axis = _infer_study_plot_axis(study_cfg, rows)
18402 if axis is None:
18403 return None, None
18404 return axis["label"], axis["values"]
18405
18406
18408 """!
18409 @brief Format a secondary study parameter compactly for a legend.
18410 @param[in] value Parameter value.
18411 @return Compact report string.
18412 """
18413 try:
18414 number = float(value)
18415 except (TypeError, ValueError):
18416 return str(value)
18417 if number.is_integer():
18418 return f"{int(number):,}"
18419 return f"{number:g}"
18420
18421
18422def _study_plot_groups(study_cfg: dict, rows: list, axis: dict, metric: str) -> list:
18423 """!
18424 @brief Group metric points by any secondary varied study parameters.
18425 @param[in] study_cfg Parsed study configuration.
18426 @param[in] rows Metrics-table rows.
18427 @param[in] axis Inferred independent-axis metadata.
18428 @param[in] metric Metric column name.
18429 @return Labeled point groups.
18430 """
18431 param_keys = [key for key in get_study_parameter_keys(study_cfg) if rows and key in rows[0]]
18432 secondary = []
18433 for key in param_keys:
18434 if key in axis["contributors"]:
18435 continue
18436 values = {str(row.get(key)) for row in rows}
18437 # A control coupled one-to-one with x (for example total_steps paired
18438 # with dt to keep physical duration fixed, or particle count paired with
18439 # grid size to keep PPC fixed) describes the same study trajectory. It
18440 # must not split that trajectory into one-point pseudo-series.
18441 if 1 < len(values) < len(rows):
18442 secondary.append(key)
18443
18444 groups = {}
18445 for row_index, row in enumerate(rows):
18446 try:
18447 y_value = float(row[metric])
18448 except (KeyError, TypeError, ValueError):
18449 continue
18450 if not math.isfinite(y_value):
18451 continue
18452 identity = tuple(row.get(key) for key in secondary)
18453 groups.setdefault(identity, []).append([axis["values"][row_index], y_value])
18454
18455 result = []
18456 for identity, points in groups.items():
18457 points.sort(key=lambda point: point[0])
18458 label = ", ".join(
18459 f"{_study_parameter_label(key)} = {_format_study_group_value(value)}"
18460 for key, value in zip(secondary, identity)
18461 )
18462 result.append({"label": label or "Study cases", "points": points})
18463 return result
18464
18465
18466def _study_use_log_scale(values: list, semantic_hint: bool = False) -> bool:
18467 """!
18468 @brief Use log scaling only for positive data spanning a meaningful range.
18469 @param[in] values Candidate axis values.
18470 @param[in] semantic_hint Whether the quantity is conventionally read logarithmically.
18471 @return True when logarithmic scaling improves interpretation.
18472 """
18473 if not values or any(value <= 0.0 for value in values):
18474 return False
18475 ratio = max(values) / min(values)
18476 return ratio >= (20.0 if semantic_hint else 100.0)
18477
18478
18479def _study_linear_y_limits(values: list) -> "tuple[float, float] | None":
18480 """!
18481 @brief Build padded linear limits that include zero for non-negative metrics.
18482 @param[in] values Finite metric values.
18483 @return Lower and upper limits, or None for an empty sequence.
18484 """
18485 if not values:
18486 return None
18487 lower, upper = min(values), max(values)
18488 if lower >= 0.0:
18489 lower = 0.0
18490 span = upper - lower
18491 padding = max(span * 0.06, abs(upper) * 0.02, 1.0e-12)
18492 return (lower, upper + padding) if lower == 0.0 else (lower - padding, upper + padding)
18493
18494def generate_study_plots(study_cfg: dict, metrics_csv: str, plots_dir: str):
18495 """!
18496 @brief Generate metric-vs-parameter plots for completed studies.
18497 @param[in] study_cfg Argument passed to `generate_study_plots()`.
18498 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
18499 @param[in] plots_dir Argument passed to `generate_study_plots()`.
18500 @return Value returned by `generate_study_plots()`.
18501 """
18502 plotting_cfg = study_cfg.get("plotting", {}) or {}
18503 if plotting_cfg.get("enabled", True) is False:
18504 print("[INFO] Plotting disabled by study.yml.")
18505 return []
18507 if plt is None:
18508 print("[WARNING] matplotlib not available; skipping plot generation.")
18509 return []
18510 if not metrics_csv or not os.path.isfile(metrics_csv):
18511 return []
18512
18513 with open(metrics_csv, "r", newline="") as f:
18514 reader = csv.DictReader(f)
18515 rows = list(reader)
18516 if not rows:
18517 return []
18518
18519 axis = _infer_study_plot_axis(study_cfg, rows)
18520 if axis is None:
18521 print("[WARNING] Could not infer numeric x-axis for plots; skipping.")
18522 return []
18523
18524 configured_metrics = study_cfg.get("metrics", []) or ["msd_final"]
18525 metric_keys = [
18526 normalize_metric_spec(metric).get("name", "metric") for metric in configured_metrics
18527 if normalize_metric_spec(metric).get("name", "metric") in rows[0]
18528 ]
18529
18530 out_format = plotting_cfg.get("output_format", "png")
18531 os.makedirs(plots_dir, exist_ok=True)
18532 generated = []
18533 for metric in metric_keys:
18534 groups = _study_plot_groups(study_cfg, rows, axis, metric)
18535 if not groups:
18536 continue
18537 all_x = [point[0] for group in groups for point in group["points"]]
18538 all_y = [point[1] for group in groups for point in group["points"]]
18539 metric_label = _study_metric_label(study_cfg, metric)
18540 metric_hint = metric.lower()
18541 y_log = _study_use_log_scale(
18542 all_y,
18543 any(token in metric_hint for token in ("error", "residual", "drift", "msd")),
18544 )
18545 x_log = _study_use_log_scale(all_x)
18546 grid_cross_product = (
18547 study_cfg.get("study_type") == "grid_independence"
18548 and bool(study_cfg.get("parameters")) and len(axis["contributors"]) == 3
18549 and sum(
18550 len(set(float(row[key]) for row in rows)) > 1
18551 for key in axis["contributors"]
18552 ) > 1
18553 )
18554
18555 plt.figure(figsize=(9.2, 5.5), facecolor="white")
18556 for index, group in enumerate(groups):
18557 x_values = [point[0] for point in group["points"]]
18558 y_values = [point[1] for point in group["points"]]
18559 repeated_x = len(set(x_values)) != len(x_values)
18560 plt.plot(
18561 x_values, y_values,
18562 color=_STUDY_REPORT_COLORS[index % len(_STUDY_REPORT_COLORS)],
18563 marker="o", markersize=5.2, markeredgewidth=0.7,
18564 linewidth=0.0 if repeated_x or grid_cross_product else 1.8,
18565 linestyle="none" if repeated_x or grid_cross_product else "-",
18566 label=group["label"],
18567 )
18568 plt.xlabel(axis["label"], fontsize=11)
18569 plt.ylabel(metric_label, fontsize=11)
18570 study_label = {
18571 "grid_independence": "Grid-independence study",
18572 "timestep_independence": "Timestep-independence study",
18573 "sensitivity": "Sensitivity study",
18574 }.get(study_cfg.get("study_type"), "Parameter study")
18575 plt.title(f"{metric_label} vs. {axis['label']}\n{study_label}", fontsize=13, loc="left", pad=12)
18576 if x_log and hasattr(plt, "xscale"):
18577 plt.xscale("log")
18578 if y_log:
18579 plt.yscale("log")
18580 elif hasattr(plt, "ylim"):
18581 limits = _study_linear_y_limits(all_y)
18582 if limits:
18583 plt.ylim(*limits)
18584 if hasattr(plt, "tick_params"):
18585 plt.tick_params(axis="both", which="major", labelsize=9.5)
18586 plt.grid(True, alpha=0.24, which="major", linewidth=0.7)
18587 if x_log or y_log:
18588 plt.grid(True, alpha=0.08, which="minor", linewidth=0.5)
18589 if len(groups) > 1:
18590 plt.legend(
18591 loc="upper left", bbox_to_anchor=(1.01, 1.0), borderaxespad=0.0,
18592 frameon=False, title="Fixed parameters", fontsize=9, title_fontsize=9.5,
18593 )
18594 plt.tight_layout(rect=(0.0, 0.0, 0.76, 1.0))
18595 else:
18596 plt.tight_layout()
18597 safe_metric = re.sub(r"[^A-Za-z0-9_.-]+", "_", metric)
18598 out_path = os.path.join(plots_dir, f"{safe_metric}_vs_{axis['slug']}.{out_format}")
18599 plt.savefig(out_path, dpi=240, bbox_inches="tight", facecolor="white")
18600 plt.close()
18601 generated.append(out_path)
18602 if generated:
18603 print(f"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
18604 return generated
18605
18606
18607def _command_to_string(command_tokens: list) -> str:
18608 """!
18609 @brief Render a command list as a shell-safe display string.
18610 @param[in] command_tokens Argument passed to `_command_to_string()`.
18611 @return Value returned by `_command_to_string()`.
18612 """
18613 return " ".join(shlex.quote(str(tok)) for tok in command_tokens)
18614
18615
18616def _resolve_post_source_directory_preview(run_dir: str, monitor_cfg: dict, post_cfg: dict) -> str:
18617 """!
18618 @brief Resolve post source directory without side effects or stdout/stderr output.
18619 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
18620 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
18621 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
18622 @return Value returned by `_resolve_post_source_directory_preview()`.
18623 """
18624 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
18625 source_dir_template = get_post_source_directory_template(post_cfg)
18626 if source_dir_template == '<solver_output_dir>':
18627 return solver_output_dir_abs
18628 return os.path.abspath(os.path.join(run_dir, source_dir_template))
18629
18630
18631def build_run_dry_plan(args) -> dict:
18632 """!
18633 @brief Build a no-write execution plan for `run --dry-run`.
18634 @param[in] args Command-line style argument list supplied to the function.
18635 @return Value returned by `build_run_dry_plan()`.
18636 """
18637 plan = {
18638 "mode": "dry-run",
18639 "created_at": datetime.now().isoformat(),
18640 "warnings": [],
18641 "inputs": {},
18642 "stages": {},
18643 "artifacts": [],
18644 }
18645
18646 if args.dry_run and args.no_submit:
18647 plan["warnings"].append("--dry-run takes precedence over --no-submit; no files will be written.")
18648
18649 cluster_mode = bool(getattr(args, "cluster", None))
18650 cluster_cfg = None
18651 cluster_path = None
18652 solver_num_procs_effective = args.num_procs
18653 post_num_procs_effective = args.num_procs
18654 run_id = None
18655 run_dir = None
18656 solver_control_path = None
18657 loaded_case_cfg = None
18658 loaded_monitor_cfg = None
18659 resolved_restart_source_dir = None
18660
18661 if cluster_mode:
18662 cluster_path = os.path.abspath(args.cluster)
18663 cluster_cfg = read_yaml_file(cluster_path)
18664 validate_cluster_config(cluster_cfg, cluster_path)
18665 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
18666 if args.scheduler and args.scheduler.lower() != scheduler_type:
18668 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18669 key="scheduler.type",
18670 file_path=cluster_path,
18671 message=f"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
18672 )
18673 sys.exit(1)
18674 if scheduler_type != "slurm":
18676 ERROR_CODE_CFG_INVALID_VALUE,
18677 key="scheduler.type",
18678 file_path=cluster_path,
18679 message=f"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported.",
18680 )
18681 sys.exit(1)
18682 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
18683 if (args.solve or args.post_process) and args.num_procs not in (1, cluster_tasks):
18685 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18686 key="resources.ntasks_per_node",
18687 file_path=cluster_path,
18688 message=(
18689 "--num-procs must be 1 (auto) or "
18690 f"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
18691 ),
18692 )
18693 sys.exit(1)
18694 if args.solve:
18695 solver_num_procs_effective = cluster_tasks
18696 if args.post_process:
18697 post_num_procs_effective = cluster_tasks
18698 plan["launch_mode"] = "slurm"
18699 plan["inputs"]["cluster"] = cluster_path
18700 else:
18701 if getattr(args, "scheduler", None):
18702 fail_cli_usage("--scheduler requires --cluster in this version.")
18703 plan["launch_mode"] = "local"
18704
18705 # --- Guard: restart flags without --solve ---
18706 if not args.solve:
18707 if getattr(args, 'restart_from', None):
18708 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
18709 if getattr(args, 'continue_run', False):
18710 print("[WARNING] --continue only extends a solve in place and is ignored here. "
18711 "Post-processing always skips steps whose output is up to date; drop "
18712 "--continue from post-only commands.", file=sys.stderr)
18713
18714 if args.solve:
18715 case_path = os.path.abspath(args.case)
18716 workspace_root = find_workspace_root(case_path, args.solver, args.monitor, os.getcwd())
18717 if workspace_root:
18718 enforce_workspace_version(workspace_root)
18719 enforce_reproducibility_policy(workspace_root)
18720 solver_path = os.path.abspath(args.solver)
18721 monitor_path = os.path.abspath(args.monitor)
18722 loaded_case_cfg = read_yaml_file(case_path)
18723 solver_cfg = read_yaml_file(solver_path)
18724 loaded_monitor_cfg = read_yaml_file(monitor_path)
18726 loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path,
18727 restart_source_given=bool(getattr(args, 'restart_from', None) or getattr(args, 'continue_run', False)),
18728 )
18729
18730 continue_mode = getattr(args, 'continue_run', False)
18731
18732 if continue_mode:
18733 # --continue reuses existing run directory
18734 if not args.run_dir:
18735 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
18736 run_dir = os.path.abspath(args.run_dir)
18737 if not os.path.isdir(run_dir):
18739 ERROR_CODE_CFG_FILE_NOT_FOUND,
18740 key="run-dir",
18741 file_path=run_dir,
18742 message="Specified run directory not found.",
18743 )
18744 sys.exit(1)
18745 adopt_relocated_artifact(run_dir, dry_run=True)
18746 run_id = os.path.basename(run_dir)
18747 else:
18748 runs_root = workspace_artifact_root(workspace_root, "runs")
18749 run_id = allocate_generated_run_id(runs_root, loaded_case_cfg, case_path)
18750 run_dir = os.path.join(runs_root, run_id)
18751
18752 try:
18753 resolved_restart_source_dir, is_continue, planned_lineage = resolve_restart_source(
18754 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir,
18755 materialize=False,
18756 )
18757 plan["lineage"] = planned_lineage or {"relationship": "root"}
18758 planned_index_bytes = select_run_petsc_index_bytes(
18759 args, loaded_case_cfg, case_path, loaded_monitor_cfg, resolved_restart_source_dir)
18760 except ValueError as e:
18762 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18763 key="restart",
18764 file_path=case_path,
18765 message=str(e),
18766 hint=_STORAGE_RESTORE_HINT if isinstance(e.__cause__, StorageError) else _RESTART_HINT,
18767 )
18768 sys.exit(1)
18769
18770 config_dir = os.path.join(run_dir, "config")
18771 scheduler_dir = os.path.join(run_dir, "scheduler")
18772 # Report the directories the run will ACTUALLY use, not the defaults. A plan
18773 # that hides a configured path cannot warn about where output really lands -
18774 # and the fresh-run log directory is deleted recursively by the C runtime.
18775 _plan_dirs = {}
18776 logs_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"])
18777 planned_output_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
18778 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
18779 profile_path = os.path.join(config_dir, "profile.run")
18780 profiling_preview = resolve_profiling_config(loaded_monitor_cfg)
18781
18782 plan["run_id_preview"] = run_id
18783 plan["run_dir_preview"] = run_dir
18784 # A layout the coarsest multigrid level cannot hold aborts grid setup on the
18785 # cluster, after the queue wait; it is decidable here from the grid's size.
18786 block_dims = resolve_grid_node_dims(
18787 loaded_case_cfg, case_path,
18788 os.path.join(run_dir, "inputs", "grid", "grid.run") if continue_mode else None)
18789 decomposition = resolve_decomposition(
18790 solver_num_procs_effective, loaded_case_cfg.get("grid") or {}, block_dims, solver_cfg,
18791 case_declares_periodic_axis(loaded_case_cfg), getattr(args, "decomposition", None),
18792 cluster_cfg if cluster_mode else None)
18793 plan.setdefault("blocking", []).extend(decomposition["problems"])
18794 plan["decomposition"] = {"layout": list(decomposition["layout"]) if decomposition.get("layout") else None,
18795 "source": decomposition["source"],
18796 "summary": describe_decomposition(decomposition, solver_num_procs_effective)}
18797 if block_dims is None and solver_num_procs_effective > 1:
18798 plan["warnings"].append(
18799 "Could not determine the grid's dimensions without building it, so the "
18800 "decomposition was not checked against the multigrid levels.")
18801 # A plan that omits what a real run would refuse is not a plan. The run
18802 # directory does not exist yet, but `realpath` still normalizes it, so the
18803 # run-root and ancestor verdicts are decidable here.
18804 _authorized, _ = resolve_unsafe_paths_override(_plan_dirs, "monitor.yml")
18805 plan.setdefault("blocking", [])
18806 # A run resumed into a directory that grew an unrouted peer would be refused,
18807 # so the plan has to say so rather than promising a launch that will not happen.
18808 structure_errors, structure_warnings = validate_run_directory_structure(run_dir)
18809 plan["blocking"].extend(structure_errors)
18810 plan["warnings"].extend(structure_warnings)
18811 for _key, _verdict, _message in classify_physical_containment(
18812 run_dir, effective_run_directories(_plan_dirs)):
18813 if _authorized and _verdict in WAIVABLE_PHYSICAL_VERDICTS:
18814 plan["warnings"].append(f"{_message} Allowed only because "
18815 f"'allow_unsafe_paths: true' is set.")
18816 else:
18817 plan["blocking"].append(_message)
18818 plan["inputs"].update({"case": case_path, "solver": solver_path, "monitor": monitor_path})
18819 asset_plan = plan_run_assets(
18820 loaded_case_cfg, case_path, unused_asset_kinds(loaded_case_cfg, solver_cfg)
18821 )
18822 plan["asset_actions"] = [
18823 {"kind": item["kind"], "provider": item["provider"], "action": item["action"]}
18824 for item in asset_plan["actions"]
18825 ]
18826 runtime_kinds = {
18827 item["kind"] for item in asset_plan["actions"]
18828 if item["execution"] == "runtime-c"
18829 }
18830 for item in asset_plan["actions"]:
18831 blocked_dependencies = runtime_kinds.intersection(item.get("dependencies", []))
18832 if item["execution"] == "precomputable" and blocked_dependencies:
18833 plan["blocking"].append(
18834 f"{item['kind']}={item['provider']} requires a file-backed "
18835 f"{', '.join(sorted(blocked_dependencies))}, but that dependency is generated "
18836 "only inside the simulator. Select a precomputable provider for the dependency."
18837 )
18838 plan["artifacts"].extend(
18839 [
18840 run_dir,
18841 *(os.path.join(run_dir, *relative.split("/"))
18842 for relative in RUN_DIRECTORY_LAYOUT),
18843 os.path.join(config_dir, "case.yml"),
18844 os.path.join(config_dir, "solver.yml"),
18845 os.path.join(config_dir, "monitor.yml"),
18846 os.path.join(config_dir, "active.json"),
18847 os.path.join(run_dir, CANONICAL_RUN_PATHS["inputs"], "assets.lock.yml"),
18848 solver_control_path,
18849 os.path.join(run_dir, "manifest.json"),
18850 ]
18851 )
18852 add_planned_grid_artifacts(plan, loaded_case_cfg, run_dir)
18853 add_planned_profile_artifacts(plan, loaded_case_cfg, run_dir)
18854 add_planned_initial_condition_artifacts(plan, loaded_case_cfg, solver_cfg, run_dir)
18855 if has_explicit_monitor_whitelist(loaded_monitor_cfg):
18856 plan["artifacts"].append(os.path.join(config_dir, "whitelist.run"))
18857 if profiling_preview["mode"] == "selected":
18858 plan["artifacts"].append(profile_path)
18859 solve_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "Solver")
18860 plan["artifacts"].extend(solve_diagnostics["artifacts"])
18861 if cluster_mode:
18862 plan["artifacts"].append(os.path.join(config_dir, "cluster.yml"))
18863 plan["artifacts"].append(os.path.join(scheduler_dir, "submission.json"))
18864
18865 executable_plan = plan_run_executable_paths(run_dir, args, workspace_root)
18866 solver_exe = executable_plan["paths"]["simulator"]
18867 if executable_plan["pinning"] == "new":
18868 plan["artifacts"].extend(executable_plan["paths"].values())
18869 solver_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "Solver") + ["-control_file", solver_control_path]
18870 if cluster_mode:
18871 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
18872 solver_cmd = build_cluster_launch_command(
18873 cluster_cfg,
18874 solver_exe,
18875 solver_args,
18876 config_search_anchor=case_path,
18877 extra_search_anchors=[cluster_path],
18878 )
18879 plan["artifacts"].append(solver_script)
18880 plan["stages"]["solve"] = {
18881 "mode": "slurm",
18882 "script": solver_script,
18883 "num_procs_effective": solver_num_procs_effective,
18884 "launch_command": solver_cmd,
18885 "launch_command_string": _command_to_string(solver_cmd),
18886 }
18887 else:
18888 solver_cmd = build_local_launch_command(
18889 solver_exe,
18890 solver_args,
18891 solver_num_procs_effective,
18892 config_search_anchor=case_path,
18893 )
18894 solver_stream_log = os.path.join(scheduler_dir, f"{run_id}_solver.log")
18895 plan["artifacts"].append(solver_stream_log)
18896 plan["stages"]["solve"] = {
18897 "mode": "local",
18898 "num_procs_effective": solver_num_procs_effective,
18899 "stream_log": solver_stream_log,
18900 "launch_command": solver_cmd,
18901 "launch_command_string": _command_to_string(solver_cmd),
18902 }
18903 plan["stages"]["solve"]["executable_pinning"] = executable_plan["pinning"]
18904 plan["stages"]["solve"]["petsc_index_bits"] = 8 * planned_index_bytes
18905 planned_run_control = loaded_case_cfg.get("run_control") or {}
18906 plan["stages"]["solve"]["start_step"] = int(planned_run_control.get("start_step", 0) or 0)
18907 plan["stages"]["solve"]["end_step"] = (plan["stages"]["solve"]["start_step"]
18908 + int(planned_run_control.get("total_steps", 0) or 0))
18909 if resolved_restart_source_dir:
18910 plan["stages"]["solve"]["restart_source_directory"] = resolved_restart_source_dir
18911 if is_continue:
18912 plan["stages"]["solve"]["continue_mode"] = True
18913
18914 if args.post_process:
18915 post_path = os.path.abspath(args.post)
18916 plan["inputs"]["post"] = post_path
18917 post_cfg = read_yaml_file(post_path)
18918 validate_post_config(post_cfg, post_path, loaded_monitor_cfg, loaded_case_cfg)
18919
18920 if args.run_dir:
18921 run_dir = os.path.abspath(args.run_dir)
18922 if not os.path.isdir(run_dir):
18924 ERROR_CODE_CFG_FILE_NOT_FOUND,
18925 key="run-dir",
18926 file_path=run_dir,
18927 message="Specified run directory not found.",
18928 )
18929 sys.exit(1)
18930 adopt_relocated_artifact(run_dir, dry_run=True)
18931 run_id = os.path.basename(run_dir)
18932 elif not args.solve:
18933 fail_cli_usage("--post-process requires --run-dir when not used with --solve.")
18934
18935 if args.run_dir:
18936 config_dir = os.path.join(run_dir, "config")
18937 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
18938 if not all([case_path, monitor_path, solver_control_path]):
18940 ERROR_CODE_CFG_MISSING_KEY,
18941 key="run_dir.config",
18942 file_path=config_dir,
18943 message=(
18944 "Could not auto-identify required run inputs "
18945 "(case.yml/monitor.yml/*.control) in run config directory."
18946 ),
18947 )
18948 sys.exit(1)
18949 loaded_case_cfg = read_yaml_file(case_path)
18950 loaded_monitor_cfg = read_yaml_file(monitor_path)
18951 else:
18952 config_dir = os.path.join(run_dir, "config")
18953 case_path = os.path.join(config_dir, "case.yml")
18954 monitor_path = os.path.join(config_dir, "monitor.yml")
18955 if solver_control_path is None:
18956 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
18957
18958 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
18959 allow_source_frontier_scan = not args.solve
18960 recompute = bool(getattr(args, 'recompute_post', False))
18961 if args.solve:
18962 post_exe = executable_plan["paths"]["postprocessor"]
18963 else:
18964 try:
18966 except ValueError as e:
18967 emit_structured_error(ERROR_CODE_CFG_INCONSISTENT_COMBO, key="petsc_index_bits",
18968 file_path=run_dir, message=str(e))
18969 sys.exit(1)
18970 post_exe = run_executable_path(run_dir, "postprocessor")
18971 post_plan = build_post_execution_plan(
18972 run_dir,
18973 run_id,
18974 loaded_case_cfg,
18975 loaded_monitor_cfg,
18976 post_cfg,
18977 recompute=recompute,
18978 allow_source_frontier_scan=allow_source_frontier_scan,
18979 postprocessor_sha=_file_sha256(post_exe),
18980 )
18981
18982 if args.solve:
18983 post_decomposition = resolve_decomposition(
18984 post_num_procs_effective, loaded_case_cfg.get("grid") or {}, block_dims, solver_cfg,
18985 case_declares_periodic_axis(loaded_case_cfg), getattr(args, "decomposition", None),
18986 cluster_cfg if cluster_mode else None)
18987 else:
18988 post_decomposition = resolve_post_decomposition(
18989 run_dir, post_num_procs_effective, getattr(args, "decomposition", None),
18990 cluster_cfg if cluster_mode else None)
18991 plan.setdefault("blocking", []).extend(
18992 problem for problem in post_decomposition["problems"] if problem not in plan["blocking"])
18993 plan["post_decomposition"] = {
18994 "layout": list(post_decomposition["layout"]) if post_decomposition.get("layout") else None,
18995 "source": post_decomposition["source"],
18996 "summary": describe_decomposition(post_decomposition, post_num_procs_effective)}
18997
18998 post_recipe_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.run")
18999 archived_post_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.yml")
19000 post_finalize_command = build_post_finalize_command(run_dir, archived_post_path, post_cfg)
19001 output_dir_rel = post_cfg.get("io", {}).get("output_directory")
19002 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix")
19003 if not output_prefix:
19005 ERROR_CODE_CFG_MISSING_KEY,
19006 key="io.output_filename_prefix",
19007 file_path=post_path,
19008 message="Missing required post output filename prefix.",
19009 )
19010 sys.exit(1)
19011 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
19012 statistics_output_paths = get_post_statistics_output_artifacts(post_cfg, run_dir, loaded_monitor_cfg)
19013 post_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "PostProcessor")
19014 plan["artifacts"].extend(post_diagnostics["artifacts"])
19015 post_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "PostProcessor") + [
19016 "-control_file",
19017 solver_control_path,
19018 "-postprocessing_config_file",
19019 post_recipe_path,
19020 ]
19021 plan["artifacts"].extend([
19022 post_recipe_path,
19023 archived_post_path,
19024 output_dir_abs,
19025 post_plan["resume_state_path"],
19026 post_plan["lock_paths"]["wrapper_path"],
19027 post_plan["lock_paths"]["lock_file"],
19028 post_plan["lock_paths"]["metadata_file"],
19029 ])
19030 plan["artifacts"].extend(statistics_output_paths)
19031 if post_finalize_command:
19032 io_cfg = post_cfg.get("io", {}) or {}
19033 if _post_requests_eulerian_output(post_cfg):
19034 plan["artifacts"].append(os.path.join(
19035 output_dir_abs, f"{io_cfg.get('output_filename_prefix', 'Field')}.pvd"
19036 ))
19037 if _post_requests_particle_output(post_cfg):
19038 plan["artifacts"].append(os.path.join(
19039 output_dir_abs, f"{io_cfg.get('particle_filename_prefix', 'Particle')}.pvd"
19040 ))
19041
19042 stage_meta = {
19043 "source_data_directory": post_plan["source_data_directory"],
19044 "requested_start_step": post_plan["requested_start_step"],
19045 "requested_end_step": post_plan["requested_end_step"],
19046 "step_interval": post_plan["step_interval"],
19047 "recompute": recompute,
19048 "steps_to_process": post_plan["steps_to_process"],
19049 "steps_current": post_plan["steps_current"],
19050 "steps_other_build": post_plan["steps_other_build"],
19051 "steps_unavailable": post_plan["steps_unavailable"],
19052 "process_reasons": post_plan["process_reasons"],
19053 "source_frontier_step": post_plan["source_frontier_step"],
19054 "source_frontier_diagnostic": post_plan["source_frontier_diagnostic"],
19055 "source_frontier_deferred": post_plan["source_frontier_deferred"],
19056 "effective_start_step": post_plan["effective_start_step"],
19057 "effective_end_step": post_plan["effective_end_step"],
19058 "skip_reason": post_plan["skip_reason"],
19059 "post_skipped_as_complete": post_plan["skip_reason"] == "already-complete-window",
19060 "recipe_fingerprint": post_plan["recipe_fingerprint"],
19061 "num_procs_effective": post_num_procs_effective,
19062 "finalize_command": post_finalize_command,
19063 }
19064
19065 if post_plan["skip_reason"] is None:
19066 if cluster_mode:
19067 scheduler_dir = os.path.join(run_dir, "scheduler")
19068 post_script = os.path.join(scheduler_dir, "post.sbatch")
19069 post_cluster_cfg = cluster_cfg
19070 raw_post_cmd = build_cluster_launch_command(
19071 post_cluster_cfg,
19072 post_exe,
19073 post_args,
19074 config_search_anchor=case_path,
19075 extra_search_anchors=[cluster_path],
19076 force_num_procs=post_num_procs_effective,
19077 )
19078 post_cmd, _ = build_post_locked_command(
19079 run_dir,
19080 post_plan["recipe_fingerprint"],
19081 raw_post_cmd,
19082 create_wrapper=False,
19083 success_command=post_finalize_command,
19084 post_recipe=archived_post_path,
19085 postprocessor=post_exe,
19086 recompute=recompute,
19087 )
19088 plan["artifacts"].append(post_script)
19089 stage_meta.update({
19090 "mode": "slurm",
19091 "script": post_script,
19092 "launch_command": post_cmd,
19093 "launch_command_string": _command_to_string(post_cmd),
19094 })
19095 else:
19096 raw_post_cmd = build_local_launch_command(
19097 post_exe,
19098 post_args,
19099 post_num_procs_effective,
19100 config_search_anchor=case_path,
19101 allow_single_rank_launcher_override=True,
19102 force_num_procs=post_num_procs_effective,
19103 )
19104 post_cmd, _ = build_post_locked_command(
19105 run_dir,
19106 post_plan["recipe_fingerprint"],
19107 raw_post_cmd,
19108 create_wrapper=False,
19109 success_command=post_finalize_command,
19110 post_recipe=archived_post_path,
19111 postprocessor=post_exe,
19112 recompute=recompute,
19113 )
19114 post_stream_log = os.path.join(run_dir, "scheduler", f"{run_id}_{output_prefix}.log")
19115 plan["artifacts"].append(post_stream_log)
19116 stage_meta.update({
19117 "mode": "local",
19118 "stream_log": post_stream_log,
19119 "launch_command": post_cmd,
19120 "launch_command_string": _command_to_string(post_cmd),
19121 })
19122 else:
19123 stage_meta.update({
19124 "mode": "slurm" if cluster_mode else "local",
19125 "launch_command": [],
19126 "launch_command_string": "",
19127 })
19128
19129 plan["stages"]["post-process"] = stage_meta
19130
19131 # Preserve insertion order while removing duplicates.
19132 deduped = []
19133 seen = set()
19134 for item in plan["artifacts"]:
19135 if item not in seen:
19136 seen.add(item)
19137 deduped.append(item)
19138 plan["artifacts"] = deduped
19139 if run_id and "run_id_preview" not in plan:
19140 plan["run_id_preview"] = run_id
19141 if run_dir and "run_dir_preview" not in plan:
19142 plan["run_dir_preview"] = run_dir
19143 plan["solver_num_procs_effective"] = solver_num_procs_effective
19144 plan["post_num_procs_effective"] = post_num_procs_effective
19145 plan["num_procs_effective"] = solver_num_procs_effective
19146 return plan
19147
19148
19149def add_planned_grid_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
19150 """!
19151 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
19152 @param[in,out] plan Dry-run plan to update.
19153 @param[in] case_cfg Parsed case configuration.
19154 @param[in] run_dir Preview run directory for relative artifact resolution.
19155 """
19156 grid_cfg = case_cfg.get("grid", {})
19157 if not isinstance(grid_cfg, dict):
19158 return
19159
19160 mode = grid_cfg.get("mode")
19161 grid_dir = os.path.join(run_dir, "inputs", "grid")
19162
19163 if mode == "file":
19164 plan["artifacts"].append(os.path.join(grid_dir, "grid.run"))
19165 elif mode == "grid_gen":
19166 generator = grid_cfg.get("generator", {})
19167 if not isinstance(generator, dict):
19168 return
19169 plan["artifacts"].extend([
19170 os.path.join(grid_dir, "grid.run"),
19171 os.path.join(grid_dir, "grid.generated.picgrid"),
19172 os.path.join(run_dir, "output", "analysis", "metrics", "grid.info"),
19173 os.path.join(run_dir, "output", "visualization", "precompute", "grid.vts"),
19174 ])
19175
19176def add_planned_profile_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
19177 """!
19178 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
19179 @param[in,out] plan Dry-run plan to update.
19180 @param[in] case_cfg Parsed case configuration.
19181 @param[in] run_dir Preview run directory.
19182 """
19183 try:
19184 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
19185 except ValueError:
19186 return
19187 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
19188 has_generated = False
19189 for block_idx, block in enumerate(prepared_blocks):
19190 for bc in block:
19191 if bc.get("handler") != "prescribed_flow":
19192 continue
19193 source = (bc.get("params") or {}).get("source", {})
19194 if source.get("type") not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
19195 continue
19196 has_generated = True
19197 face_token = _face_artifact_token(bc["face"])
19198 suffix = "generated" if source.get("type") == "generated" else "sliced"
19199 generated_path = os.path.join(
19200 profile_dir, f"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice"
19201 )
19202 staged_path = os.path.join(profile_dir, f"inlet_profile_block{block_idx}_{face_token}.picslice")
19203 plan["artifacts"].append(generated_path)
19204 plan["artifacts"].append(staged_path)
19205 if has_generated:
19206 plan["artifacts"].append(os.path.join(profile_dir, "profile.info"))
19207
19208def add_planned_initial_condition_artifacts(plan: dict, case_cfg: dict, solver_cfg: dict, run_dir: str) -> None:
19209 """!
19210 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
19211 @param[in,out] plan Dry-run plan receiving artifact paths.
19212 @param[in] case_cfg Parsed case configuration.
19213 @param[in] solver_cfg Parsed solver configuration.
19214 @param[in] run_dir Planned run directory.
19215 """
19217 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
19218 )
19219 start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
19220 if source != "solve" or start_step != 0:
19221 return
19222 try:
19223 fluid_scaling = resolve_fluid_scaling(case_cfg)
19225 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
19227 scales=fluid_scaling,
19228 provider_context={"kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]},
19229 )
19230 except (KeyError, ValueError):
19231 return
19232 if resolved["kind"] != "file" and not is_generated_ic_provider(resolved):
19233 return
19234 initial_dir = os.path.join(run_dir, "inputs", "initial_condition")
19235 plan["artifacts"].append(
19236 os.path.join(initial_dir, f"{resolved['field_name']}00000_0.dat")
19237 )
19238 if is_generated_ic_provider(resolved):
19239 if (case_cfg.get("grid", {}) or {}).get("mode") == "programmatic_c":
19240 plan["artifacts"].append(os.path.join(run_dir, "inputs", "grid", "grid.run"))
19241 plan["artifacts"].append(os.path.join(initial_dir, "initial_condition.generated.dat"))
19242 plan["artifacts"].extend(initial_condition_diagnostic_paths(run_dir, resolved))
19243
19244
19245def render_run_dry_plan(plan: dict, output_format: str = "text"):
19246 """!
19247 @brief Render dry-run plan in human or JSON format.
19248 @param[in] plan Dry-run plan produced by build_run_dry_plan().
19249 @param[in] output_format Either "text" or "json".
19250 @return 1 when the plan carries blocking findings, 0 otherwise.
19251 """
19252 if output_format == "json":
19253 print(json.dumps(plan, indent=2, sort_keys=True))
19254 return 1 if plan.get("blocking") else 0
19255
19256 for message in plan.get("warnings", []):
19257 print(f"[WARNING] {message}", file=sys.stderr)
19258 if plan.get("blocking"):
19259 print("[FATAL] This configuration would be refused. The plan below is what the "
19260 "run WOULD do; it will not get that far:", file=sys.stderr)
19261 for message in plan["blocking"]:
19262 print(f" {message}", file=sys.stderr)
19263
19264 print("\n" + "=" * 60)
19265 print(" DRY-RUN PLAN")
19266 print("=" * 60)
19267 print(f" Launch mode : {plan.get('launch_mode')}")
19268 print(f" Created at : {plan.get('created_at')}")
19269 if plan.get("run_id_preview"):
19270 print(f" Run ID preview : {plan.get('run_id_preview')}")
19271 if plan.get("run_dir_preview"):
19272 print(f" Run dir preview : {plan.get('run_dir_preview')}")
19273 lineage = plan.get("lineage") or {}
19274 if lineage.get("relationship") == "branch":
19275 print(f" Branched from : {lineage.get('parent_run_id')} "
19276 f"@ step {lineage.get('checkpoint_step')} "
19277 f"(statistics: {lineage.get('statistics_state')})")
19278 planned_stages = plan.get("stages") or {}
19279 if "solve" in planned_stages:
19280 print(f" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
19281 if "start_step" in planned_stages["solve"]:
19282 print(f" Solver steps : {planned_stages['solve']['start_step']} -> "
19283 f"{planned_stages['solve']['end_step']}")
19284 if "post-process" in planned_stages:
19285 print(f" Post MPI procs : {plan.get('post_num_procs_effective')}")
19286 for key, label in (("decomposition", "Solver"), ("post_decomposition", "Post")):
19287 if plan.get(key):
19288 print(f" {label} {plan[key]['summary']}")
19289 if plan.get("warnings"):
19290 print(" Warnings :")
19291 for warning in plan["warnings"]:
19292 print(f" - {warning}")
19293
19294 if plan.get("inputs"):
19295 print("\n Inputs:")
19296 for key, value in plan["inputs"].items():
19297 print(f" - {key}: {value}")
19298
19299 if plan.get("stages"):
19300 print("\n Planned stage commands:")
19301 for stage, details in plan["stages"].items():
19302 print(f" - {stage} ({details.get('mode')}):")
19303 if details.get('skip_reason'):
19304 print(f" skipped: {details.get('skip_reason')}")
19305 else:
19306 print(f" {details.get('launch_command_string')}")
19307
19308 if plan.get("asset_actions"):
19309 print("\n Asset resolution:")
19310 for item in plan["asset_actions"]:
19311 print(f" - {item['kind']}: {item['action']} ({item['provider']})")
19312
19313 diagnostics_artifacts = [item for item in plan.get("artifacts", []) if "PETSc_" in os.path.basename(str(item)) or os.path.basename(str(item)) == RUNTIME_MEMORY_FILE]
19314 if diagnostics_artifacts:
19315 print("\n Diagnostics artifacts:")
19316 for artifact in diagnostics_artifacts:
19317 print(f" - {artifact}")
19318
19319 print("\n Planned artifacts (no files created in dry-run):")
19320 for artifact in plan.get("artifacts", []):
19321 print(f" - {artifact}")
19322 print("=" * 60)
19323
19324
19326 """!
19327 @brief Implements `picurv validate` without launching solver/post workflows.
19328 @param[in] args Command-line style argument list supplied to the function.
19329 """
19330 checked = []
19331 solver_group_selected = any([args.case, args.solver, args.monitor])
19332 any_group_selected = solver_group_selected or any([args.post, args.cluster, args.study])
19333 case_path = None
19334 cluster_path = None
19335
19336 if getattr(args, 'continue_run', False) and not getattr(args, 'run_dir', None):
19337 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
19338 if not any_group_selected:
19340 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
19341 hint="Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
19342 )
19343
19344 if solver_group_selected and not all([args.case, args.solver, args.monitor]):
19345 fail_cli_usage("When solver validation is requested, --case, --solver, and --monitor are all required.")
19346
19347 # --- Guard: restart flags without solver group ---
19348 restart_from = getattr(args, 'restart_from', None)
19349 continue_run = getattr(args, 'continue_run', False)
19350 run_dir_val = getattr(args, 'run_dir', None)
19351 if not solver_group_selected:
19352 if restart_from:
19353 print("[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
19354 if continue_run:
19355 print("[WARNING] --continue validates a solve continued in place and needs "
19356 "--case/--solver/--monitor; it is ignored here.", file=sys.stderr)
19357
19358 monitor_cfg = None
19359 case_cfg = None
19360 if solver_group_selected:
19361 case_path = os.path.abspath(args.case)
19362 solver_path = os.path.abspath(args.solver)
19363 monitor_path = os.path.abspath(args.monitor)
19364 case_cfg = read_yaml_file(case_path)
19365 solver_cfg = read_yaml_file(solver_path)
19366 monitor_cfg = read_yaml_file(monitor_path)
19368 case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path,
19369 restart_source_given=bool(restart_from or continue_run),
19370 )
19371 checked.extend([case_path, solver_path, monitor_path])
19372
19373 # Validate restart flags if provided
19374 if restart_from or continue_run:
19375 target_run_dir = os.path.abspath(run_dir_val) if run_dir_val else os.path.abspath("runs/_validate_dummy")
19376 try:
19377 resolve_restart_source(args, case_cfg, solver_cfg, monitor_cfg, target_run_dir)
19378 print("[SUCCESS] Restart source validation passed.")
19379 except ValueError as e:
19380 print(f"[FATAL] Restart validation failed: {e}", file=sys.stderr)
19381 sys.exit(1)
19382
19383 post_cfg = None
19384 if args.post:
19385 post_path = os.path.abspath(args.post)
19386 post_cfg = read_yaml_file(post_path)
19387 validate_post_config(post_cfg, post_path, monitor_cfg, case_cfg)
19388 checked.append(post_path)
19389
19390 cluster_cfg = None
19391 if args.cluster:
19392 cluster_path = os.path.abspath(args.cluster)
19393 cluster_cfg = read_yaml_file(cluster_path)
19394 validate_cluster_config(cluster_cfg, cluster_path)
19395 checked.append(cluster_path)
19396
19397 # The rank layout that run --dry-run checks; without a cluster profile the process
19398 # count is unknown, so only each axis is checked against the coarsest multigrid level.
19399 if solver_group_selected:
19400 grid_cfg = case_cfg.get("grid") or {}
19401 block_dims = resolve_grid_node_dims(case_cfg, case_path)
19402 if cluster_cfg:
19403 tasks = get_cluster_total_tasks(cluster_cfg)
19404 decomposition = resolve_decomposition(tasks, grid_cfg, block_dims, solver_cfg,
19405 case_declares_periodic_axis(case_cfg), None, cluster_cfg)
19406 layout_problems = decomposition["problems"]
19407 if not layout_problems:
19408 print(f"[INFO] {describe_decomposition(decomposition, tasks)}")
19409 else:
19410 layout_problems = da_processor_layout_problems(
19411 grid_cfg, None, block_dims, solver_cfg, case_declares_periodic_axis(case_cfg))
19412 if layout_problems:
19414 ERROR_CODE_CFG_INCONSISTENT_COMBO,
19415 key="grid.da_processors",
19416 file_path=case_path,
19417 message=" ".join(layout_problems),
19418 )
19419 sys.exit(1)
19420 if block_dims is None and resolve_grid_da_processor_layout(grid_cfg):
19421 print(f"[WARNING] {case_path}: could not determine the grid's dimensions without building "
19422 "it, so the da_processors layout was not checked against the multigrid levels.",
19423 file=sys.stderr)
19424
19425 try:
19426 runtime_execution_path, _ = load_runtime_execution_config(
19427 case_path,
19428 extra_search_anchors=[cluster_path] if cluster_path else None,
19429 )
19430 except ValueError as exc:
19432 ERROR_CODE_CFG_INVALID_VALUE,
19433 key="runtime_execution",
19434 file_path=case_path or cluster_path or os.getcwd(),
19435 message=str(exc),
19436 )
19437 sys.exit(1)
19438 if runtime_execution_path:
19439 checked.append(runtime_execution_path)
19440
19441 study_cfg = None
19442 if args.study:
19443 study_path = os.path.abspath(args.study)
19444 study_cfg = read_yaml_file(study_path)
19445 validate_study_config(study_cfg, study_path)
19446 checked.append(study_path)
19447
19448 if post_cfg is not None and run_dir_val:
19449 post_path = os.path.abspath(args.post)
19450 validate_run_dir = os.path.abspath(run_dir_val)
19451 if os.path.isdir(validate_run_dir):
19452 monitor_for_post = monitor_cfg if solver_group_selected else None
19453 if monitor_for_post is None:
19454 config_dir_candidate = os.path.join(validate_run_dir, "config")
19455 monitor_candidate = os.path.join(config_dir_candidate, "monitor.yml")
19456 if os.path.isfile(monitor_candidate):
19457 monitor_for_post = read_yaml_file(monitor_candidate)
19458 if monitor_for_post is not None:
19459 resolved_source = _resolve_post_source_directory_preview(validate_run_dir, monitor_for_post, post_cfg)
19460 if os.path.isdir(resolved_source) and os.listdir(resolved_source):
19461 print(f"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
19462 else:
19463 print(f"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
19464
19465 if post_cfg is not None:
19466 # apply_canonical_post_paths always reads the run's own output, so a configured
19467 # directory is never used; say so instead of checking that it exists.
19468 source_dir = (post_cfg.get("source_data") or {}).get("directory")
19469 if source_dir and source_dir != "<solver_output_dir>":
19470 print(f"[WARNING] {os.path.abspath(args.post)}: source_data.directory '{source_dir}' is "
19471 "ignored; post-processing always reads the run's own output. Remove the key.",
19472 file=sys.stderr)
19473
19474 if args.strict and study_cfg is not None:
19475 study_path = os.path.abspath(args.study)
19476 base_cfgs = study_cfg.get("base_configs", {})
19477 if isinstance(base_cfgs, dict):
19478 base_case_path = resolve_path(study_path, base_cfgs.get("case"))
19479 base_solver_path = resolve_path(study_path, base_cfgs.get("solver"))
19480 base_monitor_path = resolve_path(study_path, base_cfgs.get("monitor"))
19481 base_post_path = resolve_path(study_path, base_cfgs.get("post"))
19482 if all([base_case_path, base_solver_path, base_monitor_path]):
19484 read_yaml_file(base_case_path),
19485 read_yaml_file(base_solver_path),
19486 read_yaml_file(base_monitor_path),
19487 base_case_path,
19488 base_solver_path,
19489 base_monitor_path,
19490 )
19491 if base_post_path:
19492 validate_post_config(read_yaml_file(base_post_path), base_post_path,
19493 read_yaml_file(base_monitor_path) if base_monitor_path else None,
19494 read_yaml_file(base_case_path) if base_case_path else None)
19495
19496 print(f"[SUCCESS] Validation completed for {len(checked)} file(s).")
19497 for path in checked:
19498 print(f" - {path}")
19499
19500ASSET_KIND_DIRECTORIES = {
19501 "grid": "grids",
19502 "initial-condition": "initial_conditions",
19503 "inlet-profiles": "inlet_profiles",
19504}
19505
19506
19507def _asset_file_sha256(path: str) -> str:
19508 """!
19509 @brief Hash an asset source or payload without loading it into memory.
19510 @param[in] path File to hash.
19511 @return Lowercase SHA-256 digest.
19512 """
19513 digest = hashlib.sha256()
19514 with open(path, "rb") as stream:
19515 for block in iter(lambda: stream.read(8 * 1024 * 1024), b""):
19516 digest.update(block)
19517 return digest.hexdigest()
19518
19519
19520def _stable_mapping_sha256(payload) -> str:
19521 """!
19522 @brief Hash a JSON-compatible value with deterministic serialization.
19523 @param[in] payload Value to hash.
19524 @return Lowercase SHA-256 digest.
19525 """
19526 encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
19527 return hashlib.sha256(encoded).hexdigest()
19528
19529
19530def _provider_source_fingerprints(value, case_path: str, key: str = "") -> dict:
19531 """!
19532 @brief Hash every existing file explicitly referenced by an asset provider.
19533 @param[in] value Provider subtree to inspect.
19534 @param[in] case_path Owning case configuration path.
19535 @param[in] key Current dotted key for diagnostics.
19536 @return Mapping of dotted path identity to path/content fingerprint.
19537 """
19538 result = {}
19539 if isinstance(value, dict):
19540 for child_key, child in value.items():
19541 dotted = f"{key}.{child_key}" if key else str(child_key)
19542 result.update(_provider_source_fingerprints(child, case_path, dotted))
19543 return result
19544 if isinstance(value, list):
19545 for index, child in enumerate(value):
19546 result.update(_provider_source_fingerprints(child, case_path, f"{key}[{index}]"))
19547 return result
19548 basename = key.rsplit(".", 1)[-1]
19549 if basename not in _ASSET_SOURCE_REFERENCE_KEYS or not isinstance(value, str) or not value.strip():
19550 return result
19551 try:
19552 resolved = resolve_workspace_path(case_path, value)
19553 except ValueError:
19554 return result
19555 if os.path.isfile(resolved):
19556 workspace_root = find_workspace_root(case_path)
19557 display = (
19558 os.path.relpath(resolved, workspace_root).replace(os.sep, "/")
19559 if workspace_root and os.path.commonpath([workspace_root, resolved]) == workspace_root
19560 else resolved
19561 )
19562 result[key] = {"path": display, "sha256": _asset_file_sha256(resolved)}
19563 return result
19564
19565
19566def build_case_asset_graph(case_cfg: dict, case_path: str) -> dict:
19567 """!
19568 @brief Classify case inputs into precomputable or simulator-runtime providers.
19569 @param[in] case_cfg Parsed case configuration.
19570 @param[in] case_path Owning case YAML path.
19571 @return Provider graph with stable identities and explicit dependencies.
19572 """
19573 providers = []
19574 grid_cfg = copy.deepcopy(case_cfg.get("grid", {}) or {})
19575 grid_mode = str(grid_cfg.get("mode", "")).strip().lower()
19576 if isinstance(grid_cfg.get("generator"), dict):
19577 grid_cfg["generator"].pop("output_file", None)
19578 grid_cfg["generator"].pop("vts_file", None)
19579 grid_cfg["generator"].pop("stats_file", None)
19580 grid_provider = {
19581 "kind": "grid",
19582 "provider": grid_mode or "missing",
19583 "execution": "runtime-c" if grid_mode == "programmatic_c" else "precomputable",
19584 "dependencies": [],
19585 "spec": grid_cfg,
19586 }
19587 providers.append(grid_provider)
19588
19589 initial = copy.deepcopy(
19590 ((case_cfg.get("properties") or {}).get("initial_conditions") or {})
19591 )
19592 initial_mode = str(initial.get("mode", "generated")).strip().lower()
19593 initial_generator = str(initial.get("generator", "constant")).strip().lower()
19594 generated_python = (
19595 initial_mode == "generated"
19596 and initial_generator in _PYTHON_INITIAL_CONDITION_PROVIDERS
19597 )
19598 initial_provider = {
19599 "kind": "initial-condition",
19600 "provider": "file" if initial_mode == "file" else initial_generator,
19601 "execution": "precomputable" if initial_mode == "file" or generated_python else "runtime-c",
19602 "dependencies": ["grid"] if generated_python else [],
19603 "spec": initial,
19604 }
19605 providers.append(initial_provider)
19606
19607 inlet_specs = []
19608 raw_blocks = case_cfg.get("boundary_conditions") or []
19609 if raw_blocks and isinstance(raw_blocks[0], dict):
19610 raw_blocks = [raw_blocks]
19611 for block_index, block in enumerate(raw_blocks):
19612 for entry in block or []:
19613 if not isinstance(entry, dict) or str(entry.get("handler", "")).strip().lower() != "prescribed_flow":
19614 continue
19615 source = copy.deepcopy(((entry.get("params") or {}).get("source") or {}))
19616 inlet_specs.append({"block": block_index, "face": entry.get("face"), "source": source})
19617 if inlet_specs:
19618 field_slice = any((item.get("source") or {}).get("type") == "field_slice" for item in inlet_specs)
19619 providers.append({
19620 "kind": "inlet-profiles",
19621 "provider": "prescribed-flow",
19622 "execution": "precomputable",
19623 "dependencies": ["grid"] if field_slice or grid_mode in _FILE_BACKED_GRID_VALUES else [],
19624 "spec": inlet_specs,
19625 })
19626
19627 # A provider's identity has to cover every case value its build reads, not only its
19628 # own configuration subtree. The grid build nondimensionalizes by
19629 # properties.scaling.length_ref and validates against the declared block count;
19630 # both are outside `grid:`, and a change to either produces different payload bytes
19631 # from an identical provider spec. Left out, the staleness check reports `reuse`
19632 # and the solver silently receives geometry scaled by the wrong reference length.
19633 scaling = (case_cfg.get("properties") or {}).get("scaling") or {}
19634 fluid = (case_cfg.get("properties") or {}).get("fluid") or {}
19635 domain_blocks = (case_cfg.get("models") or {}).get("domain", {}).get("blocks", 1)
19636 build_contexts = {
19637 "grid": {
19638 "length_ref": scaling.get("length_ref"),
19639 "blocks": domain_blocks,
19640 },
19641 # The IC build resolves fluid scaling and the prepared boundary conditions.
19642 "initial-condition": {
19643 "length_ref": scaling.get("length_ref"),
19644 "velocity_ref": scaling.get("velocity_ref"),
19645 "density": fluid.get("density"),
19646 "viscosity": fluid.get("viscosity"),
19647 "boundary_conditions": case_cfg.get("boundary_conditions"),
19648 },
19649 # Profile generation dimensionalizes against the same scaling contract. On a
19650 # programmatic_c grid, which is not an asset the profile can depend on, it samples
19651 # the bridge built from programmatic_settings, so those settings are read too.
19652 "inlet-profiles": {
19653 "length_ref": scaling.get("length_ref"),
19654 "velocity_ref": scaling.get("velocity_ref"),
19655 "blocks": domain_blocks,
19656 "programmatic_settings": (
19657 grid_cfg.get("programmatic_settings") if grid_mode == "programmatic_c" else None
19658 ),
19659 },
19660 }
19661
19662 # Hash in dependency order so a provider built on top of another re-identifies when
19663 # the thing it was built from changes.
19664 by_kind = {provider["kind"]: provider for provider in providers}
19665 resolved_order = sorted(
19666 providers, key=lambda provider: len(provider.get("dependencies") or [])
19667 )
19668 for provider in resolved_order:
19669 provider["build_context"] = build_contexts.get(provider["kind"], {})
19670 provider["source_files"] = _provider_source_fingerprints(provider["spec"], case_path)
19671 provider["software"] = {
19672 "release_version": PICURV_RELEASE_VERSION,
19673 "git_commit": picurv_build().get("git_commit"),
19674 }
19675 provider["spec_sha256"] = _stable_mapping_sha256({
19676 "kind": provider["kind"],
19677 "provider": provider["provider"],
19678 "spec": provider["spec"],
19679 "build_context": provider["build_context"],
19680 "source_files": provider["source_files"],
19681 "software": provider["software"],
19682 "dependencies": {
19683 name: by_kind[name].get("spec_sha256")
19684 for name in (provider.get("dependencies") or [])
19685 if name in by_kind
19686 },
19687 })
19688 return {
19689 "case_sha256": _stable_mapping_sha256(case_cfg),
19690 "providers": providers,
19691 }
19692
19693
19694def _asset_selection(graph: dict, requested=None, *, precomputable_only: bool = False) -> list:
19695 """!
19696 @brief Resolve requested asset kinds plus dependency closure.
19697 @param[in] graph Provider graph returned by build_case_asset_graph.
19698 @param[in] requested Optional iterable of requested kind names.
19699 @param[in] precomputable_only Exclude runtime-C providers for normal-run staging.
19700 @return Ordered provider list.
19701 """
19702 by_kind = {item["kind"]: item for item in graph["providers"]}
19703 requested_set = set(requested or by_kind)
19704 unknown = requested_set - set(by_kind)
19705 if unknown:
19706 raise ValueError(
19707 f"No configured provider exists for asset kind(s): {sorted(unknown)}. "
19708 f"Configured kinds: {sorted(by_kind)}."
19709 )
19710 closure = set()
19711
19712 def add(kind):
19713 """!
19714 @brief Add one requested asset kind and its dependency closure.
19715 @param[in] kind Asset kind to include.
19716 @return None.
19717 """
19718 if kind in closure:
19719 return
19720 closure.add(kind)
19721 for dependency in by_kind[kind].get("dependencies", []):
19722 if dependency in by_kind:
19723 add(dependency)
19724
19725 for kind in requested_set:
19726 add(kind)
19727 selected = [item for item in graph["providers"] if item["kind"] in closure]
19728 if precomputable_only:
19729 selected = [item for item in selected if item["execution"] == "precomputable"]
19730 return selected
19731
19732
19733def _asset_payload_files(build_root: str, kind: str) -> list:
19734 """!
19735 @brief Enumerate canonical files belonging to one asset kind in a build tree.
19736 @param[in] build_root Temporary run-like build root.
19737 @param[in] kind Asset kind.
19738 @return Absolute payload file paths.
19739 """
19740 roots = {
19741 "grid": ["inputs/grid", "output/analysis/metrics", "output/visualization/precompute"],
19742 "initial-condition": ["inputs/initial_condition", "output/analysis/spectra", "output/analysis/metrics"],
19743 "inlet-profiles": ["inputs/inlet_profiles"],
19744 }[kind]
19745 result = []
19746 for relative in roots:
19747 root = Path(build_root, *relative.split("/"))
19748 if root.is_dir():
19749 result.extend(str(path) for path in sorted(root.rglob("*")) if path.is_file())
19750 intermediate_names = {
19751 "grid": {"grid.generated.picgrid", "grid.converted.picgrid"},
19752 "initial-condition": {"initial_condition.generated.dat"},
19753 "inlet-profiles": set(),
19754 }[kind]
19755 return [
19756 path for path in dict.fromkeys(result)
19757 if os.path.basename(path) not in intermediate_names
19758 ]
19759
19760
19761def _build_selected_asset_payloads(build_root: str, case_cfg: dict, case_path: str,
19762 selected: list) -> dict:
19763 """!
19764 @brief Execute existing generators into one isolated run-like build tree.
19765 @param[in] build_root Temporary output root.
19766 @param[in] case_cfg Parsed case config.
19767 @param[in] case_path Owning case path.
19768 @param[in] selected Ordered selected providers.
19769 @return Asset-kind to generated file list.
19770 """
19771 ensure_run_layout(build_root)
19772 selected_kinds = {item["kind"] for item in selected}
19773 payloads = {}
19774
19775 def fingerprint(kind):
19776 """!
19777 @brief Snapshot current payload checksums for one asset kind.
19778 @param[in] kind Asset kind to inspect.
19779 @return Absolute-path to checksum mapping.
19780 """
19781 return {
19782 path: _asset_file_sha256(path)
19783 for path in _asset_payload_files(build_root, kind)
19784 }
19785
19786 def record_changes(kind, before):
19787 """!
19788 @brief Record files created or changed by one provider stage.
19789 @param[in] kind Asset kind whose build just completed.
19790 @param[in] before Pre-build checksum mapping.
19791 @return None.
19792 """
19793 after = fingerprint(kind)
19794 payloads[kind] = [
19795 path for path, digest in after.items()
19796 if before.get(path) != digest
19797 ]
19798
19799 grid_cfg = case_cfg.get("grid", {}) or {}
19800 expected_nblk = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
19801 staged_grid = os.path.join(build_root, "inputs", "grid", "grid.run")
19802 if "grid" in selected_kinds:
19803 before = fingerprint("grid")
19804 if grid_cfg.get("mode") in _FILE_BACKED_GRID_VALUES:
19805 grid_length_scale = input_reference_scale(("case", "grid", "source_file"),
19806 resolve_reference_scales(case_cfg))
19807 if grid_cfg.get("mode") == "grid_gen":
19808 generated = run_grid_generator(case_path, build_root, grid_cfg, case_cfg=case_cfg)
19810 generated, staged_grid, grid_length_scale, expected_nblk=expected_nblk
19811 )
19812 elif grid_cfg.get("mode") == "file":
19814 grid_cfg.get("source_file"), os.path.dirname(os.path.abspath(case_path))
19815 )
19817 source, staged_grid, grid_length_scale, expected_nblk=expected_nblk
19818 )
19819 record_changes("grid", before)
19820
19821 if "inlet-profiles" in selected_kinds:
19822 before = fingerprint("inlet-profiles")
19824 build_root, "asset-build", case_cfg,
19825 {"Case": case_path},
19826 )
19827 record_changes("inlet-profiles", before)
19828
19829 if "initial-condition" in selected_kinds:
19830 before = fingerprint("initial-condition")
19831 fluid_scaling = resolve_fluid_scaling(case_cfg)
19833 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
19835 scales=fluid_scaling,
19836 provider_context={
19837 "kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]
19838 },
19839 )
19840 if (grid_cfg.get("mode") == "programmatic_c" and is_generated_ic_provider(resolved_ic)
19841 and not os.path.isfile(staged_grid)):
19842 # "grid" is excluded from this closure when the caller passed
19843 # precomputable_only=True (materialize_run_assets, staging before a solve): a
19844 # runtime-C grid is never itself precomputed. But the Python IC generator run
19845 # below is a separate process from the solver and needs real coordinates on
19846 # disk regardless. This bridge uses the identical formula as
19847 # ComputeStretchedCoord in src/grid.c, so it is guaranteed to match what the
19848 # solver builds moments later; it is not published as its own asset, since a
19849 # programmatic_c grid is never a persisted asset in its own right.
19851 grid_cfg.get("programmatic_settings", {}), staged_grid,
19852 input_reference_scale(("case", "grid", "programmatic_settings", "xMins"), fluid_scaling),
19853 )
19854 stage_initial_condition_file(build_root, case_path, resolved_ic)
19855 record_changes("initial-condition", before)
19856 return payloads
19857
19858
19859#: Largest grid, in nodes, for which a published asset carries an inline VTS preview.
19860#: Beyond this the ASCII payload costs more than the inspection is worth, and
19861#: validation.json records that it was skipped rather than silently omitting it.
19862ASSET_PREVIEW_NODE_LIMIT = 2_000_000
19863
19864
19865def _picgrid_geometry_summary(path: str) -> dict:
19866 """!
19867 @brief Read a canonical PICGRID and summarize what a user would want to check.
19868 @param[in] path Staged PICGRID file.
19869 @return Mapping of block dimensions, bounds, and spacing extremes.
19870 """
19872 bounds = [[float("inf")] * 3, [float("-inf")] * 3]
19873 nodes = 0
19874 with open(path, "r", encoding="utf-8", errors="replace") as stream:
19875 iterator = _iter_nonempty_noncomment_lines(stream)
19876 # Header token, block count, and one dimension row per block.
19877 for _ in range(2 + len(dims)):
19878 next(iterator, None)
19879 for _lineno, line in iterator:
19880 parts = line.split()
19881 if len(parts) != 3:
19882 continue
19883 try:
19884 point = [float(value) for value in parts]
19885 except ValueError:
19886 continue
19887 nodes += 1
19888 for axis in range(3):
19889 bounds[0][axis] = min(bounds[0][axis], point[axis])
19890 bounds[1][axis] = max(bounds[1][axis], point[axis])
19891 extent = [bounds[1][axis] - bounds[0][axis] for axis in range(3)] if nodes else [0.0, 0.0, 0.0]
19892 return {
19893 "blocks": len(dims),
19894 "dimensions": [list(item) for item in dims],
19895 "total_nodes": nodes,
19896 "bounds_min": bounds[0] if nodes else None,
19897 "bounds_max": bounds[1] if nodes else None,
19898 "extent": extent,
19899 }
19900
19901
19902def _write_structured_grid_preview(picgrid_path: str, destination: str, dims) -> bool:
19903 """!
19904 @brief Write a single-block ASCII VTS preview of a staged PICGRID.
19905
19906 @details Only the first block is written: the preview exists so a user can confirm
19907 the shape they configured, not to reproduce the solver's view of a
19908 multi-block domain.
19909 @param[in] picgrid_path Staged PICGRID file.
19910 @param[in] destination Preview path to write.
19911 @param[in] dims Per-block dimension triples.
19912 @return True when a preview was written.
19913 """
19914 if not dims:
19915 return False
19916 im, jm, km = (int(value) for value in dims[0])
19917 if im * jm * km > ASSET_PREVIEW_NODE_LIMIT:
19918 return False
19919 coordinates = []
19920 with open(picgrid_path, "r", encoding="utf-8", errors="replace") as stream:
19921 iterator = _iter_nonempty_noncomment_lines(stream)
19922 for _ in range(2 + len(dims)):
19923 next(iterator, None)
19924 for _lineno, line in iterator:
19925 parts = line.split()
19926 if len(parts) == 3:
19927 coordinates.append(line)
19928 if len(coordinates) >= im * jm * km:
19929 break
19930 if len(coordinates) < im * jm * km:
19931 return False
19932 os.makedirs(os.path.dirname(destination), exist_ok=True)
19933 with open(destination, "w", encoding="utf-8") as out:
19934 extent = f"0 {im - 1} 0 {jm - 1} 0 {km - 1}"
19935 out.write('<?xml version="1.0"?>\n')
19936 out.write('<VTKFile type="StructuredGrid" version="1.0" byte_order="LittleEndian">\n')
19937 out.write(f' <StructuredGrid WholeExtent="{extent}">\n')
19938 out.write(f' <Piece Extent="{extent}">\n')
19939 out.write(' <Points>\n')
19940 out.write(' <DataArray type="Float64" NumberOfComponents="3" format="ascii">\n')
19941 for row in coordinates:
19942 out.write(f" {row}\n")
19943 out.write(' </DataArray>\n </Points>\n')
19944 out.write(' </Piece>\n </StructuredGrid>\n</VTKFile>\n')
19945 return True
19946
19947
19948def _build_asset_inspection(kind: str, build_root: str, provider: dict,
19949 payload_files: list) -> dict:
19950 """!
19951 @brief Produce the inspection material published beside an asset's payload.
19952
19953 @details Precompute exists so a user can look at a grid, field, or profile and
19954 change it before committing a solve. An asset that carries only opaque
19955 solver input cannot serve that purpose, so every published object gets a
19956 validation record and, where it is affordable, a preview.
19957 @param[in] kind Asset kind.
19958 @param[in] build_root Temporary build tree.
19959 @param[in] provider Provider metadata for the asset.
19960 @param[in] payload_files Absolute payload paths produced for this asset.
19961 @return Mapping of published inspection filename to its absolute source path.
19962 """
19963 inspection_dir = os.path.join(build_root, ".inspection")
19964 os.makedirs(inspection_dir, exist_ok=True)
19965 published = {}
19966 validation = {
19967 "asset_kind": kind,
19968 "provider": provider["provider"],
19969 "generated_at": datetime.now().astimezone().isoformat(),
19970 "files": [os.path.relpath(path, build_root).replace(os.sep, "/") for path in payload_files],
19971 }
19972
19973 if kind == "grid":
19974 staged = os.path.join(build_root, "inputs", "grid", "grid.run")
19975 if os.path.isfile(staged):
19976 try:
19977 geometry = _picgrid_geometry_summary(staged)
19978 validation["geometry"] = geometry
19979 preview = os.path.join(inspection_dir, "preview.vts")
19980 if _write_structured_grid_preview(staged, preview, geometry["dimensions"]):
19981 published["preview.vts"] = preview
19982 else:
19983 validation["preview"] = (
19984 "skipped: the first block exceeds "
19985 f"{ASSET_PREVIEW_NODE_LIMIT} nodes"
19986 )
19987 except (OSError, ValueError, StopIteration) as exc:
19988 validation["geometry_error"] = str(exc)
19989 generator_preview = os.path.join(
19990 build_root, "output", "visualization", "precompute", "grid.vts"
19991 )
19992 if os.path.isfile(generator_preview):
19993 published["preview.vts"] = generator_preview
19994 validation.pop("preview", None)
19995 info = os.path.join(build_root, "output", "analysis", "metrics", "grid.info")
19996 if os.path.isfile(info):
19997 published["grid.info"] = info
19998
19999 elif kind == "initial-condition":
20000 # The generators name these for the run tree they were written into; the asset
20001 # publishes them under the names the object contract uses.
20002 for published_name, suffix in (
20003 ("summary.json", "_summary.json"), ("spectrum.csv", "_spectrum.csv"),
20004 ):
20005 for candidate in payload_files:
20006 base = os.path.basename(candidate)
20007 if base == published_name or base.endswith(suffix):
20008 published[published_name] = candidate
20009 break
20010 for candidate in payload_files:
20011 base = os.path.basename(candidate)
20012 if base.startswith(("initial_condition_plane_spectrum_", "initial_condition_line_spectrum_")) and base.endswith((".csv", ".json")):
20013 published[base] = candidate
20014 validation["fields"] = sorted(
20015 os.path.basename(path) for path in payload_files if path.endswith(".dat")
20016 )
20017 summary_source = published.get("summary.json")
20018 if summary_source:
20019 try:
20020 with open(summary_source, "r", encoding="utf-8") as stream:
20021 validation["summary"] = json.load(stream)
20022 except (OSError, ValueError):
20023 pass
20024
20025 elif kind == "inlet-profiles":
20026 for candidate in payload_files:
20027 if os.path.basename(candidate) == "profile.info":
20028 published["profile.info"] = candidate
20029 validation["profiles"] = sorted(
20030 os.path.basename(path) for path in payload_files
20031 if path.endswith(".picslice")
20032 )
20033
20034 validation_path = os.path.join(inspection_dir, "validation.json")
20035 write_json_file(validation_path, validation)
20036 published["validation.json"] = validation_path
20037 return published
20038
20039
20040def _publish_asset_object(workspace_root: str, build_root: str, provider: dict,
20041 payload_files: list) -> dict:
20042 """!
20043 @brief Publish one immutable content-addressed asset object atomically.
20044 @param[in] workspace_root Owning workspace.
20045 @param[in] build_root Temporary build root.
20046 @param[in] provider Provider metadata.
20047 @param[in] payload_files Files produced for the provider.
20048 @return Asset reference written to the asset set.
20049 """
20050 file_inventory = []
20051 for path in payload_files:
20052 relative = os.path.relpath(path, build_root).replace(os.sep, "/")
20053 file_inventory.append({
20054 "path": relative,
20055 "bytes": os.path.getsize(path),
20056 "sha256": _asset_file_sha256(path),
20057 })
20058 asset_id = _stable_mapping_sha256({
20059 "provider_spec_sha256": provider["spec_sha256"],
20060 "files": file_inventory,
20061 })
20062 # Identity is the payload's; inspection material describes it and must not change
20063 # which object a run resolves to.
20064 inspection = _build_asset_inspection(
20065 provider["kind"], build_root, provider, payload_files
20066 )
20067 kind_dir = ASSET_KIND_DIRECTORIES[provider["kind"]]
20068 object_root = os.path.join(workspace_root, "assets", "objects", kind_dir, asset_id)
20069 if not os.path.isdir(object_root):
20070 parent = os.path.dirname(object_root)
20071 os.makedirs(parent, exist_ok=True)
20072 temporary = tempfile.mkdtemp(prefix=f".{asset_id[:12]}-", dir=parent)
20073 try:
20074 for item, source in zip(file_inventory, payload_files):
20075 destination = os.path.join(temporary, "payload", *item["path"].split("/"))
20076 os.makedirs(os.path.dirname(destination), exist_ok=True)
20077 shutil.copy2(source, destination)
20078 for name, source in sorted(inspection.items()):
20079 shutil.copy2(source, os.path.join(temporary, name))
20080 manifest = {
20081 "schema_version": ASSET_MANIFEST_SCHEMA_VERSION,
20082 "asset_id": asset_id,
20083 "kind": provider["kind"],
20084 "provider": provider["provider"],
20085 "provider_execution": provider["execution"],
20086 "provider_spec_sha256": provider["spec_sha256"],
20087 "provider_spec": provider["spec"],
20088 "source_files": provider["source_files"],
20089 "software": provider["software"],
20090 "created_at": datetime.now().astimezone().isoformat(),
20091 "files": file_inventory,
20092 "inspection": sorted(inspection),
20093 }
20094 write_json_file(os.path.join(temporary, "asset.json"), manifest)
20095 try:
20096 os.replace(temporary, object_root)
20097 except OSError as exc:
20098 if exc.errno != errno.ENOTEMPTY and not os.path.isdir(object_root):
20099 raise
20100 finally:
20101 if os.path.isdir(temporary):
20102 shutil.rmtree(temporary, ignore_errors=True)
20103 return {
20104 "asset_id": asset_id,
20105 "kind": provider["kind"],
20106 "provider": provider["provider"],
20107 "provider_spec_sha256": provider["spec_sha256"],
20108 "object": os.path.relpath(object_root, workspace_root).replace(os.sep, "/"),
20109 "files": file_inventory,
20110 "inspection": sorted(inspection),
20111 }
20112
20113
20114def _workspace_asset_set_name(workspace_root: str, case_path: str) -> str:
20115 """!
20116 @brief Return a collision-free, readable mutable asset-set name.
20117 @param[in] workspace_root Owning initialized workspace.
20118 @param[in] case_path Source case configuration path.
20119 @return Stable asset-set filename stem.
20120 """
20121 stem = re.sub(r"[^A-Za-z0-9_.-]+", "-", Path(case_path).stem).strip("-") or "case"
20122 relative = _relative_to_workspace(case_path, workspace_root) or os.path.abspath(case_path)
20123 digest = hashlib.sha256(str(relative).encode("utf-8")).hexdigest()[:10]
20124 return f"{stem}-{digest}"
20125
20126
20127def _write_workspace_asset_set(workspace_root: str, case_path: str, graph: dict,
20128 references: dict) -> str:
20129 """!
20130 @brief Atomically update the named asset set and workspace asset catalog.
20131 @param[in] workspace_root Owning workspace.
20132 @param[in] case_path Source case YAML.
20133 @param[in] graph Complete provider graph.
20134 @param[in] references Newly published or reused asset references.
20135 @return Asset-set YAML path.
20136 """
20137 name = _workspace_asset_set_name(workspace_root, case_path)
20138 set_path = os.path.join(workspace_root, "assets", "sets", f"{name}.yml")
20139 existing = read_yaml_file(set_path) if os.path.isfile(set_path) else {}
20140 assets = dict(existing.get("assets") or {})
20141 assets.update(references)
20142 payload = {
20143 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20144 "name": name,
20145 "case": os.path.relpath(case_path, workspace_root).replace(os.sep, "/"),
20146 "case_sha256": graph["case_sha256"],
20147 "updated_at": datetime.now().astimezone().isoformat(),
20148 "assets": assets,
20149 "runtime_providers": {
20150 item["kind"]: {
20151 "provider": item["provider"],
20152 "provider_spec_sha256": item["spec_sha256"],
20153 }
20154 for item in graph["providers"] if item["execution"] == "runtime-c"
20155 },
20156 }
20157 write_yaml_file(set_path, payload)
20158 catalog_path = os.path.join(workspace_root, "assets", "catalog.yml")
20159 catalog = read_yaml_file(catalog_path) if os.path.isfile(catalog_path) else {
20160 "schema_version": 1, "objects": {}
20161 }
20162 objects = catalog.setdefault("objects", {})
20163 for reference in references.values():
20164 objects[reference["asset_id"]] = {
20165 "kind": reference["kind"],
20166 "provider": reference["provider"],
20167 "object": reference["object"],
20168 }
20169 write_yaml_file(catalog_path, catalog)
20170 return set_path
20171
20172
20173def precompute_case_assets(workspace_root: str, case_cfg: dict, case_path: str,
20174 requested=None, precomputable_only: bool = False) -> dict:
20175 """!
20176 @brief Build and publish a selected deterministic asset dependency closure.
20177 @param[in] workspace_root Owning workspace.
20178 @param[in] case_cfg Parsed case configuration.
20179 @param[in] case_path Source case path.
20180 @param[in] requested Requested asset kinds, or all configured providers.
20181 @param[in] precomputable_only Drop runtime-C dependencies from the closure instead of
20182 refusing. For internal run-staging only: a run legitimately
20183 depends on a runtime-C grid that its own solver builds
20184 moments later, so its presence in the dependency closure is
20185 expected there, not an error. The standalone `picurv
20186 precompute` command leaves this False, since nothing will
20187 ever build a runtime-C provider in that context.
20188 @return Provider graph, selected providers, references, and asset-set path.
20189 """
20190 enforce_workspace_version(workspace_root)
20191 graph = build_case_asset_graph(case_cfg, case_path)
20192 selected = _asset_selection(graph, requested, precomputable_only=precomputable_only)
20193 if not precomputable_only:
20194 runtime = [item for item in selected if item["execution"] == "runtime-c"]
20195 if runtime:
20196 details = ", ".join(f"{item['kind']}={item['provider']}" for item in runtime)
20197 raise ValueError(
20198 "Precompute is atomic and cannot execute simulator-runtime providers. "
20199 f"Selected dependency graph requires C generation: {details}. "
20200 "Use --only to select an independent precomputable subset, or run the case; "
20201 "the simulator will report each runtime provider before generation."
20202 )
20203 if not selected:
20204 raise ValueError("The selected case has no precomputable providers.")
20205 staging_parent = os.path.join(workspace_root, "assets")
20206 os.makedirs(staging_parent, exist_ok=True)
20207 build_root = tempfile.mkdtemp(prefix=".precompute-", dir=staging_parent)
20208 references = {}
20209 try:
20210 payloads = _build_selected_asset_payloads(build_root, case_cfg, case_path, selected)
20211 for provider in selected:
20212 files = payloads.get(provider["kind"], [])
20213 if not files:
20214 raise ValueError(
20215 f"Provider {provider['kind']}={provider['provider']} produced no files."
20216 )
20217 references[provider["kind"]] = _publish_asset_object(
20218 workspace_root, build_root, provider, files
20219 )
20220 finally:
20221 shutil.rmtree(build_root, ignore_errors=True)
20222 set_path = _write_workspace_asset_set(workspace_root, case_path, graph, references)
20223 return {"graph": graph, "selected": selected, "assets": references, "set_path": set_path}
20224
20225
20226def _workspace_asset_set_path(workspace_root: str, case_path: str) -> str:
20227 """!
20228 @brief Return the mutable asset-set pointer associated with a case config name.
20229 @param[in] workspace_root Owning workspace.
20230 @param[in] case_path Source case path.
20231 @return Absolute asset-set YAML path.
20232 """
20233 name = _workspace_asset_set_name(workspace_root, case_path)
20234 return os.path.join(workspace_root, "assets", "sets", f"{name}.yml")
20235
20236
20237def _find_stored_asset(workspace_root: str, provider: dict):
20238 """!
20239 @brief Find a published object built from exactly this provider's inputs.
20240 @details Asset sets are named after case files, so they miss an object another case
20241 file already built from identical inputs. The provider identity is what the
20242 object was built from, so any published object carrying it is reusable; the
20243 lowest asset id is taken so repeated lookups agree. Payload checksums are
20244 verified when the object is materialized, not here.
20245 @param[in] workspace_root Owning workspace.
20246 @param[in] provider Provider entry from build_case_asset_graph().
20247 @return Asset reference in the shape _publish_asset_object() returns, or None.
20248 """
20249 kind_dir = ASSET_KIND_DIRECTORIES.get(provider["kind"])
20250 if not kind_dir:
20251 return None
20252 objects_root = os.path.join(workspace_root, "assets", "objects", kind_dir)
20253 if not os.path.isdir(objects_root):
20254 return None
20255 for asset_id in sorted(os.listdir(objects_root)):
20256 object_root = os.path.join(objects_root, asset_id)
20257 manifest = _read_json_if_exists(os.path.join(object_root, "asset.json"))
20258 if not (isinstance(manifest, dict)
20259 and manifest.get("schema_version") == ASSET_MANIFEST_SCHEMA_VERSION
20260 and manifest.get("asset_id") == asset_id
20261 and manifest.get("kind") == provider["kind"]
20262 and manifest.get("provider_spec_sha256") == provider["spec_sha256"]):
20263 continue
20264 return {
20265 "asset_id": asset_id,
20266 "kind": provider["kind"],
20267 "provider": manifest.get("provider", provider["provider"]),
20268 "provider_spec_sha256": provider["spec_sha256"],
20269 "object": os.path.relpath(object_root, workspace_root).replace(os.sep, "/"),
20270 "files": manifest.get("files", []),
20271 "inspection": manifest.get("inspection", []),
20272 }
20273 return None
20274
20275
20276def plan_run_assets(case_cfg: dict, case_path: str, skip_kinds=()) -> dict:
20277 """!
20278 @brief Plan reuse/build/runtime actions for all configured run providers.
20279 @param[in] case_cfg Parsed case configuration.
20280 @param[in] case_path Source case path.
20281 @param[in] skip_kinds Kinds the run will not read; planned as `unused`, never built.
20282 @return Workspace, graph, set path, and per-provider actions.
20283 """
20284 workspace_root = find_workspace_root(case_path)
20285 graph = build_case_asset_graph(case_cfg, case_path)
20286 asset_set = {}
20287 set_path = None
20288 if workspace_root:
20289 set_path = _workspace_asset_set_path(workspace_root, case_path)
20290 if os.path.isfile(set_path):
20291 asset_set = read_yaml_file(set_path)
20292 refs = asset_set.get("assets", {}) if isinstance(asset_set, dict) else {}
20293 actions = []
20294 for provider in graph["providers"]:
20295 reference = refs.get(provider["kind"]) if isinstance(refs, dict) else None
20296 matches = bool(
20297 isinstance(reference, dict)
20298 and reference.get("provider_spec_sha256") == provider["spec_sha256"]
20299 and workspace_root
20300 and os.path.isfile(os.path.join(
20301 workspace_root, reference.get("object", ""), "asset.json"
20302 ))
20303 )
20304 adopted = False
20305 if provider["kind"] in skip_kinds:
20306 actions.append({**provider, "action": "unused", "reference": None, "adopted": False})
20307 continue
20308 if not matches and workspace_root and provider["execution"] != "runtime-c":
20309 # The asset set is named after the case file, so a copied or renamed case
20310 # has none; identity is the provider's, so an object built for another case
20311 # file with the same inputs is the same asset.
20312 reference = _find_stored_asset(workspace_root, provider)
20313 matches = adopted = reference is not None
20314 if provider["execution"] == "runtime-c":
20315 action = "runtime-c"
20316 elif matches:
20317 action = "reuse"
20318 else:
20319 action = "build"
20320 actions.append({**provider, "action": action, "reference": reference if matches else None,
20321 "adopted": adopted})
20322 return {
20323 "workspace_root": workspace_root,
20324 "graph": graph,
20325 "asset_set_path": set_path,
20326 "actions": actions,
20327 }
20328
20329
20330def _materialize_asset_file(source: str, destination: str) -> str:
20331 """!
20332 @brief Expose one immutable shared-asset file through reflink, hardlink, or copy.
20333 @param[in] source Immutable asset payload file.
20334 @param[in] destination Run-local destination.
20335 @return Materialization mode used.
20336 """
20337 os.makedirs(os.path.dirname(destination), exist_ok=True)
20338 temporary = f"{destination}.tmp.{os.getpid()}"
20339 try:
20340 cp = shutil.which("cp")
20341 if cp:
20342 result = subprocess.run(
20343 [cp, "--reflink=always", "--preserve=mode,timestamps", source, temporary],
20344 text=True, capture_output=True, check=False,
20345 )
20346 if result.returncode == 0:
20347 os.replace(temporary, destination)
20348 return "reflink"
20349 if os.path.lexists(temporary):
20350 os.remove(temporary)
20351 try:
20352 os.link(source, temporary)
20353 os.replace(temporary, destination)
20354 return "hardlink"
20355 except OSError:
20356 if os.path.lexists(temporary):
20357 os.remove(temporary)
20358 shutil.copy2(source, temporary)
20359 os.replace(temporary, destination)
20360 return "copy"
20361 finally:
20362 if os.path.lexists(temporary):
20363 os.remove(temporary)
20364
20365
20366def materialize_run_assets(run_dir: str, case_cfg: dict, case_path: str,
20367 require_precomputed: bool = False,
20368 fetch_missing: bool = False, skip_kinds=()) -> dict:
20369 """!
20370 @brief Resolve/build workspace assets and write the exact run input lock.
20371 @param[in] run_dir Run receiving immutable input exposures.
20372 @param[in] case_cfg Parsed case config.
20373 @param[in] case_path Source case path.
20374 @param[in] require_precomputed Refuse any missing deterministic asset.
20375 @param[in] fetch_missing Request remote fetch before local build.
20376 @param[in] skip_kinds Kinds the run will not read (see unused_asset_kinds()).
20377 @return Written lock mapping, or a standalone-provider summary outside a workspace.
20378 """
20379 plan = plan_run_assets(case_cfg, case_path, skip_kinds)
20380 workspace_root = plan["workspace_root"]
20381 if not workspace_root:
20382 return {
20383 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20384 "workspace": None,
20385 "assets": {},
20386 "runtime_providers": {
20387 item["kind"]: item["provider"] for item in plan["actions"]
20388 if item["execution"] == "runtime-c"
20389 },
20390 }
20391 enforce_workspace_version(workspace_root)
20392 missing = [item for item in plan["actions"] if item["action"] == "build"]
20393 if missing and fetch_missing:
20394 # Storage imports this module indirectly, so remote asset recovery is routed
20395 # through a late import that cannot create a module cycle at startup.
20396 try:
20397 from .storage import restore_missing_workspace_assets
20398 except ImportError:
20399 restore_missing_workspace_assets = getattr(
20400 _storage_module, "restore_missing_workspace_assets", None
20401 )
20402 restored = restore_missing_workspace_assets(
20403 workspace_root, [item["spec_sha256"] for item in missing]
20404 ) if restore_missing_workspace_assets else {}
20405 if restored:
20406 recovered_refs = {
20407 item["kind"]: restored[item["spec_sha256"]]
20408 for item in missing if item["spec_sha256"] in restored
20409 }
20410 if recovered_refs:
20412 workspace_root, case_path, plan["graph"], recovered_refs
20413 )
20414 plan = plan_run_assets(case_cfg, case_path, skip_kinds)
20415 missing = [item for item in plan["actions"] if item["action"] == "build"]
20416 if missing and require_precomputed:
20417 names = ", ".join(f"{item['kind']}={item['provider']}" for item in missing)
20418 raise ValueError(
20419 f"Required precomputed asset(s) are missing or stale: {names}. "
20420 f"Run 'picurv precompute --case {case_path} --only "
20421 + ",".join(item["kind"] for item in missing) + "'."
20422 )
20423 if missing:
20425 workspace_root, case_cfg, case_path,
20426 requested=[item["kind"] for item in missing],
20427 precomputable_only=True,
20428 )
20429 plan = plan_run_assets(case_cfg, case_path, skip_kinds)
20430 still_missing = [item for item in plan["actions"] if item["action"] == "build"]
20431 if still_missing:
20432 raise ValueError(
20433 "Asset generation completed without satisfying: "
20434 + ", ".join(item["kind"] for item in still_missing)
20435 )
20436
20437 adopted = {
20438 item["kind"]: item["reference"] for item in plan["actions"] if item.get("adopted")
20439 }
20440 if adopted:
20441 # Found by provider identity rather than through this case's own set; record it
20442 # there so the next run of this case file resolves it directly.
20443 _write_workspace_asset_set(workspace_root, case_path, plan["graph"], adopted)
20444 assets = {}
20445 for item in plan["actions"]:
20446 reference = item.get("reference")
20447 if item["action"] != "reuse" or not isinstance(reference, dict):
20448 continue
20449 object_root = os.path.join(workspace_root, reference["object"])
20450 manifest = _read_json_if_exists(os.path.join(object_root, "asset.json"))
20451 if not isinstance(manifest, dict) or manifest.get("asset_id") != reference.get("asset_id"):
20452 raise ValueError(f"Asset object is missing or invalid: {object_root}")
20453 exposed = []
20454 for file_info in manifest.get("files", []):
20455 relative = file_info["path"]
20456 source = os.path.join(object_root, "payload", *relative.split("/"))
20457 if _asset_file_sha256(source) != file_info.get("sha256"):
20458 raise ValueError(f"Asset payload checksum mismatch: {source}")
20459 destination = os.path.join(run_dir, *relative.split("/"))
20460 mode = _materialize_asset_file(source, destination)
20461 exposed.append({"path": relative, "mode": mode, "sha256": file_info["sha256"]})
20462 assets[item["kind"]] = {**reference, "exposed": exposed}
20463 print(f"[INFO] Asset {item['kind']}: reuse {reference['asset_id']}")
20464 runtime_providers = {
20465 item["kind"]: {
20466 "provider": item["provider"],
20467 "provider_spec_sha256": item["spec_sha256"],
20468 }
20469 for item in plan["actions"]
20470 if item["execution"] == "runtime-c" and item["action"] != "unused"
20471 }
20472 for kind, provider in runtime_providers.items():
20473 print(f"[INFO] Runtime provider {kind}: {provider['provider']} (generated by simulator)")
20474 lock = {
20475 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20476 "workspace": workspace_root,
20477 "case_sha256": plan["graph"]["case_sha256"],
20478 "created_at": datetime.now().astimezone().isoformat(),
20479 "assets": assets,
20480 "runtime_providers": runtime_providers,
20481 }
20482 write_yaml_file(os.path.join(run_dir, "inputs", "assets.lock.yml"), lock)
20483 write_software_lock(run_dir)
20484 return lock
20485
20486
20488 """!
20489 @brief Resolve, preflight, and atomically publish reusable workspace assets.
20490 @param[in] args Parsed precompute command arguments.
20491 """
20492 case_path = os.path.abspath(args.case)
20493 workspace_root = find_workspace_root(case_path, os.getcwd())
20494 if not workspace_root:
20495 raise ValueError(
20496 "Precompute requires an initialized PICurv workspace. Run 'picurv init ...', "
20497 "then use its config/case.yml."
20498 )
20499 case_cfg = read_yaml_file(case_path)
20500 raw_only = getattr(args, "only", None)
20501 requested = None
20502 if raw_only and str(raw_only).strip().lower() != "all":
20503 requested = [token.strip() for token in str(raw_only).split(",") if token.strip()]
20504 graph = build_case_asset_graph(case_cfg, case_path)
20505 selected = _asset_selection(graph, requested)
20506 print(f"[INFO] Workspace : {workspace_root}")
20507 print(f"[INFO] Case : {os.path.relpath(case_path, workspace_root)}")
20508 print("[INFO] Asset dependency plan:")
20509 for provider in selected:
20510 dependencies = ",".join(provider.get("dependencies", [])) or "none"
20511 print(
20512 f" - {provider['kind']}: {provider['provider']} "
20513 f"[{provider['execution']}], dependencies={dependencies}"
20514 )
20515 result = precompute_case_assets(workspace_root, case_cfg, case_path, requested=requested)
20516 for kind, reference in sorted(result["assets"].items()):
20517 print(f"[SUCCESS] {kind}: {reference['asset_id']} ({reference['object']})")
20518 print(f"[SUCCESS] Asset set: {os.path.relpath(result['set_path'], workspace_root)}")
20519
20521 """!
20522 @brief Main orchestrator for the 'run' command (local and Slurm modes).
20523 @param[in] args Command-line style argument list supplied to the function.
20524 """
20525 if getattr(args, "dry_run", False):
20526 plan = build_run_dry_plan(args)
20527 render_run_dry_plan(plan, output_format=getattr(args, "output_format", "text"))
20528 if plan.get("blocking"):
20529 sys.exit(1)
20530 return
20531
20532 run_dir = None
20533 run_id = None
20534 output_dir_abs = None
20535 # A post-only invocation never resolves a restart source, so this stays None and
20536 # `build_run_manifest()` preserves whatever lineage the run was created with.
20537 run_lineage = None
20538 statistics_output_paths = []
20539 workflow_start = time.time()
20540 stages_completed = []
20541 configs = None
20542 submission_meta = {"launch_mode": "local", "no_submit": bool(args.no_submit), "stages": {}}
20543
20544 cluster_mode = bool(getattr(args, "cluster", None))
20545 cluster_cfg = None
20546 cluster_path = None
20547 solver_num_procs_effective = args.num_procs
20548 post_num_procs_effective = args.num_procs
20549
20550 if cluster_mode:
20551 cluster_path = os.path.abspath(args.cluster)
20552 cluster_cfg = read_yaml_file(cluster_path)
20553 validate_cluster_config(cluster_cfg, cluster_path)
20554 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
20555 if args.scheduler and args.scheduler.lower() != scheduler_type:
20557 ERROR_CODE_CFG_INCONSISTENT_COMBO, key="scheduler.type", file_path=cluster_path,
20558 message=f"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
20559 hint="Omit --scheduler, or give the scheduler cluster.yml names.",
20560 )
20561 sys.exit(1)
20562 if scheduler_type != "slurm":
20564 ERROR_CODE_CFG_INVALID_VALUE, key="scheduler.type", file_path=cluster_path,
20565 message=f"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported.",
20566 hint="Set scheduler.type: slurm in cluster.yml.",
20567 )
20568 sys.exit(1)
20569 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
20570 if (args.solve or args.post_process) and args.num_procs not in (1, cluster_tasks):
20572 "In cluster mode, --num-procs must be "
20573 f"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
20574 hint="Omit -n to use the task count cluster.yml sets.",
20575 )
20576 if args.solve:
20577 solver_num_procs_effective = cluster_tasks
20578 if args.post_process:
20579 post_num_procs_effective = cluster_tasks
20580 submission_meta["launch_mode"] = "slurm"
20581 submission_meta["cluster_config"] = cluster_path
20582 submission_meta["no_submit"] = bool(args.no_submit)
20583 if args.solve and args.post_process:
20584 print(
20585 f"[INFO] Cluster mode enabled (Slurm). Solver and post stages use "
20586 f"{solver_num_procs_effective} MPI tasks from cluster.yml."
20587 )
20588 elif args.solve:
20589 print(f"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks from cluster.yml.")
20590 else:
20591 print(f"[INFO] Cluster mode enabled (Slurm). Post stage uses {post_num_procs_effective} MPI tasks from cluster.yml.")
20592
20593 # --- Guard: restart flags without --solve ---
20594 if not args.solve:
20595 if getattr(args, 'restart_from', None):
20596 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
20597 if getattr(args, 'continue_run', False):
20598 print("[WARNING] --continue only extends a solve in place and is ignored here. "
20599 "Post-processing always skips steps whose output is up to date; drop "
20600 "--continue from post-only commands.", file=sys.stderr)
20601
20602 # --- Stage 1: Solver (if requested) ---
20603 if args.solve:
20604 case_input_path = os.path.abspath(args.case)
20605 workspace_root = find_workspace_root(case_input_path, args.solver, args.monitor, os.getcwd())
20606 if workspace_root:
20607 enforce_workspace_version(workspace_root)
20608 enforce_reproducibility_policy(workspace_root)
20609 walltime_guard_policy = resolve_walltime_guard_policy(cluster_cfg) if cluster_mode else None
20610 configs = {
20611 'case': read_yaml_file(args.case), 'case_path': case_input_path,
20612 'solver': read_yaml_file(args.solver), 'solver_path': os.path.abspath(args.solver),
20613 'monitor': read_yaml_file(args.monitor), 'monitor_path': os.path.abspath(args.monitor),
20614 'walltime_guard_policy': walltime_guard_policy,
20615 'statistics_state': getattr(args, "statistics_state", None) or "reset",
20616 'decomposition': getattr(args, "decomposition", None),
20617 'cluster_cfg': cluster_cfg if cluster_mode else None,
20618 }
20619
20620 print("\n[INFO] Validating configuration files...")
20622 configs['case'], configs['solver'], configs['monitor'],
20623 args.case, args.solver, args.monitor,
20624 restart_source_given=bool(getattr(args, 'restart_from', None) or getattr(args, 'continue_run', False)),
20625 )
20626 print("[SUCCESS] All configuration files passed validation.\n")
20627
20628 continue_mode = getattr(args, 'continue_run', False)
20629
20630 if continue_mode:
20631 if not args.run_dir:
20632 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
20633 run_dir = os.path.abspath(args.run_dir)
20634 if not os.path.isdir(run_dir):
20636 ERROR_CODE_CFG_FILE_NOT_FOUND,
20637 key="run-dir",
20638 file_path=run_dir,
20639 message="Specified run directory not found.",
20640 )
20641 sys.exit(1)
20643 run_id = os.path.basename(run_dir)
20644 else:
20645 runs_root = workspace_artifact_root(workspace_root, "runs")
20647 runs_root, configs["case"], configs["case_path"]
20648 )
20649 run_dir = os.path.join(runs_root, run_id)
20650
20651 if workspace_root and os.path.commonpath([os.path.abspath(run_dir), workspace_root]) != workspace_root:
20652 fail_cli_usage("A workspace run directory must remain below the owning workspace.")
20653 if not continue_mode and os.path.exists(run_dir):
20654 raise ValueError(f"Generated run directory already exists: {run_dir}")
20655 ensure_run_layout(run_dir)
20656 # Checked after the skeleton exists so a resumed run is judged on what is
20657 # actually there, and before any stage writes into it.
20659
20660 # Captured before restart resolution, which materializes the checkpoint it reads.
20661 staged_records = (capture_continuation_records(run_dir, resolve_run_restart_dir(run_dir, configs["monitor"]))
20662 if continue_mode else None)
20663 try:
20664 resolved_restart_source_dir, is_continue, run_lineage = resolve_restart_source(
20665 args, configs["case"], configs["solver"], configs["monitor"], run_dir
20666 )
20667 select_run_petsc_index_bytes(args, configs["case"], configs["case_path"], configs["monitor"],
20668 resolved_restart_source_dir)
20669 except ValueError as e:
20670 # Restart resolution is the first thing that can refuse a run, and the
20671 # skeleton is already on disk. Leaving it behind puts an empty directory in
20672 # runs/ that is indistinguishable from a real run to `ls`, to storage
20673 # status, and to anyone browsing the workspace, once per refused attempt.
20674 discard_unused_run_directory(run_dir, created=not continue_mode)
20676 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20677 key="restart",
20678 file_path=args.case,
20679 message=str(e),
20680 hint=_STORAGE_RESTORE_HINT if isinstance(e.__cause__, StorageError) else _RESTART_HINT,
20681 )
20682 sys.exit(1)
20683
20684 config_dir = os.path.join(run_dir, "config")
20685 if continue_mode:
20686 print(f"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
20687 else:
20688 print(f"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
20689
20690 # Read before staging, which rewrites a continuation's own grid inputs.
20691 source_run_dir = run_dir if is_continue else (
20692 os.path.join(workspace_root or "", run_lineage["parent_path"]) if run_lineage else None)
20693 source_grid_inputs = None
20694 if source_run_dir and os.path.isdir(source_run_dir):
20695 source_case_path = load_active_run_configuration(source_run_dir).get("case")
20696 if source_case_path and os.path.isfile(source_case_path):
20697 source_grid_inputs = restart_grid_inputs(source_run_dir, read_yaml_file(source_case_path))
20698 active_config = snapshot_run_configuration(
20699 run_dir,
20700 {
20701 "case": args.case,
20702 "solver": args.solver,
20703 "monitor": args.monitor,
20704 "cluster": cluster_path if cluster_mode else None,
20705 },
20706 continuation=continue_mode,
20707 )
20708 # Pinned before the asset lock and manifest are written, so both record the
20709 # executables this run will launch rather than the installation's.
20710 had_pinned_executables = bool(load_run_executables(run_dir))
20712 try:
20713 apply_run_executable_pinning(run_dir, active_config["revision"], args, workspace_root)
20714 except ValueError as exc:
20715 discard_unused_run_directory(run_dir, created=not continue_mode)
20716 print(f"[FATAL] {exc}", file=sys.stderr)
20717 sys.exit(1)
20718 try:
20719 asset_lock = materialize_run_assets(
20720 run_dir,
20721 configs["case"],
20722 configs["case_path"],
20723 require_precomputed=bool(getattr(args, "require_precomputed", False)),
20724 fetch_missing=bool(getattr(args, "fetch_missing", False)),
20725 skip_kinds=unused_asset_kinds(configs["case"], configs["solver"]),
20726 )
20727 except ValueError as exc:
20728 discard_unused_run_directory(run_dir, created=not continue_mode)
20729 print(f"[FATAL] {exc}", file=sys.stderr)
20730 sys.exit(1)
20731 # Publish identity and lineage before either executable starts. Besides making
20732 # a staged run self-describing, this lets a dependent post job resolve branch
20733 # ownership even though the interactive conductor has already exited.
20735 os.path.join(run_dir, "manifest.json"),
20737 run_dir,
20738 run_id,
20739 workspace_root=workspace_root,
20740 launch_mode="slurm" if cluster_mode else "local",
20741 num_procs=solver_num_procs_effective,
20742 post_num_procs=post_num_procs_effective,
20743 stages_requested={"solve": True, "post_process": bool(args.post_process)},
20744 inputs={
20745 "case": _relative_to_workspace(args.case, workspace_root),
20746 "solver": _relative_to_workspace(args.solver, workspace_root),
20747 "monitor": _relative_to_workspace(args.monitor, workspace_root),
20748 },
20749 asset_lock=asset_lock,
20750 lineage=run_lineage,
20751 ),
20752 )
20753
20754 print("\n" + "="*25 + " SOLVER STAGE " + "="*25)
20755 source_files = {'Case': args.case, 'Solver': args.solver, 'Monitor': args.monitor}
20756 generated_config_dir = (
20757 config_dir if active_config["revision"] == "initial"
20758 else os.path.join(config_dir, "history", active_config["revision"])
20759 )
20760 monitor_files = prepare_monitor_files(
20761 run_dir, run_id, configs['monitor'], source_files,
20762 config_dir=generated_config_dir,
20763 )
20764 if resolved_restart_source_dir:
20765 print(f"[INFO] Restart source: {resolved_restart_source_dir}")
20766 if is_continue:
20767 print("[INFO] Continue mode: logs will be appended, not overwritten.")
20768 control_file = generate_solver_control_file(
20769 run_dir,
20770 run_id,
20771 configs,
20772 solver_num_procs_effective,
20773 monitor_files,
20774 restart_source_dir=resolved_restart_source_dir,
20775 continue_mode=is_continue,
20776 config_dir=generated_config_dir,
20777 )
20779 run_dir,
20780 [
20781 control_file,
20782 monitor_files.get("whitelist"),
20783 monitor_files.get("profile"),
20784 *glob.glob(os.path.join(generated_config_dir, "bcs*.run")),
20785 ],
20786 )
20787 control_file = load_active_run_configuration(run_dir).get("control", control_file)
20788
20789 solver_exe = run_executable_path(run_dir, "simulator")
20790 # Staging is the last point before the binary's identity becomes the one written
20791 # into this run's checkpoints, so a stale build is worth saying out loud here.
20792 # A run kept on its earlier pin is meant to differ from the source, so it is not.
20793 if not had_pinned_executables or getattr(args, "pin_executables", None) is True:
20795 solver_args = build_petsc_diagnostics_args(configs["monitor"], run_dir, "Solver") + ["-control_file", control_file]
20796 if source_grid_inputs is not None:
20797 warn_on_restart_grid_change(source_grid_inputs, restart_grid_inputs(run_dir, configs["case"]))
20798 carries_statistics = is_continue or str(configs.get("statistics_state", "reset")).lower() == "carry"
20799 if carries_statistics and normalize_field_statistics_config(configs["monitor"])["enabled"]:
20800 refusal = check_restart_continuation(solver_exe, solver_args, run_dir,
20801 config_search_anchor=configs["case_path"])
20802 if refusal:
20803 if continue_mode:
20804 restore_continuation_records(run_dir, staged_records, active_config["revision"])
20805 undone = f"{os.path.relpath(run_dir)} is left as it was"
20806 else:
20807 discard_unused_run_directory(run_dir, created=True, staged=True)
20808 undone = "the branch run directory was removed"
20809 print(f"[FATAL] This restart cannot continue its statistics windows: {refusal}\n"
20810 f" Nothing was launched or submitted, and {undone}.", file=sys.stderr)
20811 sys.exit(1)
20812 if cluster_mode:
20813 scheduler_dir = os.path.join(run_dir, "scheduler")
20814 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
20815 solver_log = os.path.join(scheduler_dir, "solver_%j.out")
20816 solver_err = os.path.join(scheduler_dir, "solver_%j.err")
20817 solver_cmd = build_cluster_launch_command(
20818 cluster_cfg,
20819 solver_exe,
20820 solver_args,
20821 config_search_anchor=args.case,
20822 extra_search_anchors=[cluster_path],
20823 )
20825 solver_script,
20826 f"{run_id}_solve",
20827 cluster_cfg,
20828 solver_cmd,
20829 run_dir,
20830 solver_log,
20831 solver_err,
20832 env_vars={"LOG_LEVEL": configs['monitor'].get('logging', {}).get('verbosity', 'INFO').upper()},
20833 shell_env_vars=build_walltime_guard_exports(cluster_cfg),
20834 identity_check=(solver_exe, read_binary_build_identity(solver_exe).get("version_line"),
20835 "simulator" in load_run_executables(run_dir)),
20836 )
20837 submission_meta["stages"]["solve"] = {
20838 "script": solver_script,
20839 "submitted": False,
20840 "num_procs_effective": solver_num_procs_effective,
20841 "decomposition": _decomposition_record(configs.get("decomposition_resolved")),
20842 }
20843 print(f"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
20844 if not args.no_submit:
20845 submit_info = submit_sbatch(solver_script)
20846 submission_meta["stages"]["solve"].update(submit_info)
20847 submission_meta["stages"]["solve"]["submitted"] = True
20848 print(f"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
20849 stages_completed.append('solve')
20850 else:
20852 solver_exe,
20853 solver_args,
20854 solver_num_procs_effective,
20855 config_search_anchor=configs["case_path"],
20856 )
20857 solver_log = os.path.join("scheduler", f"{run_id}_solver.log")
20858 submission_meta["stages"]["solve"] = {
20859 "command": command,
20860 "command_string": format_command_for_display(command),
20861 "log_file": solver_log,
20862 "submitted": False,
20863 "num_procs_effective": solver_num_procs_effective,
20864 "decomposition": _decomposition_record(configs.get("decomposition_resolved")),
20865 }
20866 if args.no_submit:
20867 print(f"[SUCCESS] Staged local solve stage; run it with "
20868 f"'picurv submit --run-dir {os.path.relpath(run_dir)}'.")
20869 else:
20870 try:
20871 with runtime_stage_lock(run_dir, "solver"):
20872 execute_command(command, run_dir, solver_log, configs['monitor'])
20873 except StorageError as exc:
20874 print(f"[FATAL] {exc}", file=sys.stderr)
20875 sys.exit(1)
20876 submission_meta["stages"]["solve"]["submitted"] = True
20877 submission_meta["stages"]["solve"]["executed"] = True
20878 submission_meta["stages"]["solve"]["completed_at"] = datetime.now().isoformat()
20879 stages_completed.append('solve')
20880
20881 # --- Stage 2: Post-Processing (if requested) ---
20882 if args.post_process:
20883 if not args.solve and getattr(args, "pin_executables", None) is not None:
20885 "--pin-executables and --no-pin-executables apply when the solve stage is staged.",
20886 hint="Post-processing launches the postprocessor the run is pinned to, or the "
20887 "installation's when the run is not pinned.",
20888 )
20889 if args.run_dir:
20890 run_dir = os.path.abspath(args.run_dir)
20891 if not os.path.isdir(run_dir):
20892 emit_structured_error(ERROR_CODE_CFG_FILE_NOT_FOUND, key="run_dir", file_path=run_dir,
20893 message="Run directory not found.")
20894 sys.exit(1)
20895 print(f"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
20898 run_id = read_artifact_identity(run_dir)["run_id"]
20899
20900 print("\n" + "="*20 + " POST-PROCESSING STAGE " + "="*20)
20901 config_dir = os.path.join(run_dir, "config")
20902 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
20903
20904 if not all([case_path, monitor_path, solver_control_path]):
20905 print(f"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
20906 if not case_path:
20907 print(" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
20908 if not monitor_path:
20909 print(" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
20910 if not solver_control_path:
20911 print(" - No '.control' file found.", file=sys.stderr)
20912 sys.exit(1)
20913
20914 print(f"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
20915 print(f"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
20916
20917 case_cfg = read_yaml_file(case_path)
20918 monitor_cfg = read_yaml_file(monitor_path)
20919 post_cfg = read_yaml_file(args.post)
20920
20921 print("[INFO] Validating post-processing configuration...")
20922 validate_post_config(post_cfg, args.post, monitor_cfg, case_cfg)
20923 print("[SUCCESS] Post-processing configuration passed validation.\n")
20924
20925 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
20926 for relative in (
20927 post_cfg["_picurv_paths"]["visualization"],
20928 post_cfg["_picurv_paths"]["statistics"],
20929 post_cfg["_picurv_paths"]["spectra"],
20930 ):
20931 os.makedirs(os.path.join(run_dir, relative), exist_ok=True)
20932 archived_post_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.yml")
20933 os.makedirs(os.path.dirname(archived_post_path), exist_ok=True)
20934 if os.path.abspath(args.post) != os.path.abspath(archived_post_path):
20935 shutil.copy2(args.post, archived_post_path)
20936
20937 solver_sources_deferred = bool(args.solve and (cluster_mode or args.no_submit))
20938 recompute = bool(getattr(args, 'recompute_post', False))
20939 post_stages = resolve_post_stage_selection(getattr(args, 'only', None))
20940 try:
20941 if not args.solve:
20943 post_exe = run_executable_path(run_dir, "postprocessor")
20944 except ValueError as exc:
20945 post_exe = None
20946 post_exe_error = exc
20947 post_plan = build_post_execution_plan(
20948 run_dir,
20949 run_id,
20950 case_cfg,
20951 monitor_cfg,
20952 post_cfg,
20953 recompute=recompute,
20954 allow_source_frontier_scan=not solver_sources_deferred,
20955 postprocessor_sha=_file_sha256(post_exe) if post_exe else None,
20956 )
20957 # Only the checkpoints of steps that need work must be local: a step whose output
20958 # is up to date is not read, so an offload that archived it does not block the run.
20959 needed_steps = post_plan['steps_to_process'] + post_plan['steps_unavailable']
20960 if 'fields' in post_stages and not solver_sources_deferred and needed_steps:
20961 try:
20962 require_storage_payload_local(
20963 run_dir, "post-processing", checkpoints=needed_steps,
20964 )
20965 except StorageError as exc:
20967 ERROR_CODE_CFG_FILE_NOT_FOUND,
20968 key="storage",
20969 file_path=run_dir,
20970 message=str(exc),
20971 hint=_STORAGE_RESTORE_HINT,
20972 )
20973 sys.exit(1)
20974
20975 post_finalize_command = (
20976 build_post_finalize_command(run_dir, archived_post_path, post_cfg, post_stages)
20977 if 'fields' in post_stages else []
20978 )
20979 if post_stages != set(POST_STAGE_NAMES):
20980 print(f"[INFO] Post stages selected: {','.join(sorted(post_stages))}")
20981 # A stage the recipe gives no tasks has nothing to do; say so rather than
20982 # report success for work that was never possible.
20983 for stage, tasks in (("spectra", normalize_post_spectra_config(post_cfg)["tasks"]),
20984 ("reducers", normalize_post_reducers_config(post_cfg)["tasks"])):
20985 if stage in post_stages and not tasks:
20986 print(f"[WARNING] --only selects {stage}, but {args.post} defines no {stage} "
20987 "tasks; nothing is measured for it.", file=sys.stderr)
20988
20989 print(f"[INFO] Post recipe: {recipe_id}")
20990 print(f"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
20991 if post_plan['source_frontier_deferred']:
20992 print("[INFO] Source checkpoints: not scanned; the solver stage writes them before post starts.")
20993 elif post_plan['source_latest_step'] is not None:
20994 print(f"[INFO] Source checkpoints: {post_plan['source_committed_count']} committed, "
20995 f"latest at step {post_plan['source_latest_step']}.")
20996 else:
20997 print("[INFO] Source checkpoints: none committed yet.")
20998
20999 # A local launch plans again under the post lock and reports that plan, so only an
21000 # invocation that stages the work, or finds none, reports this one.
21001 executes_fields_now = (
21002 'fields' in post_stages and post_plan['skip_reason'] is None
21003 and not cluster_mode and not args.no_submit
21004 )
21005 if 'fields' in post_stages:
21006 if not executes_fields_now:
21007 report_post_plan(post_plan)
21008 warn_overlapping_post_recipes(run_dir, post_cfg, post_plan, post_stages)
21009 persist_post_resume_state(run_dir, post_plan, steps=post_plan['adopted_records'])
21010
21011 # Spectra run in the conductor rather than in the submitted job, so they can
21012 # only be measured when this invocation is the one doing the work. Under a
21013 # scheduler, with --no-submit, or before the solver has written anything,
21014 # the sources do not exist yet and the measurement is deferred instead.
21015 spectra_execute_now = (
21016 not cluster_mode
21017 and not args.no_submit
21018 and not post_plan['source_frontier_deferred']
21019 )
21020 fields_job_measures_spectra = (
21021 cluster_mode and 'fields' in post_stages and post_plan['skip_reason'] is None
21022 )
21023 if 'spectra' in post_stages and spectra_execute_now:
21024 spectra_summary = run_post_spectra_stage(
21025 run_dir,
21026 post_plan['resolved_post_cfg'],
21027 monitor_cfg,
21028 post_plan['source_data_directory'],
21029 _iter_post_steps(post_plan['owned_start_step'], post_plan['requested_end_step'],
21030 post_plan['step_interval']),
21031 plan=post_plan,
21032 recompute=recompute,
21033 )
21034 for artifact in spectra_summary['artifacts']:
21035 print(f"[INFO] Spectra output: {os.path.relpath(artifact)}")
21036 elif 'spectra' in post_stages and not fields_job_measures_spectra:
21037 reason = ("the solver stage has not produced output yet"
21038 if post_plan['source_frontier_deferred'] else
21039 "this invocation only stages work")
21040 print(f"[INFO] Spectra deferred: {reason}. Measure them once the run has "
21041 f"checkpoints with:")
21042 print(f"[INFO] picurv run --post-process --only spectra "
21043 f"--run-dir {os.path.relpath(run_dir)} --post {args.post}")
21044
21045 # Without the field stage nothing finalizes, so selected reducers run here, on
21046 # the profiles earlier field stages wrote.
21047 if 'reducers' in post_stages and 'fields' not in post_stages:
21048 try:
21049 run_post_reducers_stage(run_dir, post_plan['resolved_post_cfg'])
21050 except ValueError as exc:
21051 print(f"[FATAL] {exc}", file=sys.stderr)
21052 sys.exit(1)
21053
21054 if 'fields' not in post_stages:
21055 # --only selected a subset that excludes the field post-processor.
21056 print("[INFO] Skipping the field post-processor (--only "
21057 f"{','.join(sorted(post_stages))}).")
21058 stages_completed.append('post-process')
21059 elif post_plan['skip_reason'] is not None:
21060 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
21061 first_incomplete = diagnostic.get('first_incomplete_step')
21062 if post_plan['skip_reason'] == 'already-complete-window':
21063 print("[INFO] Every requested step is up to date; the postprocessor is not launched.")
21064 elif post_plan['skip_reason'] == 'already-caught-up-to-current-source-frontier':
21065 print("[INFO] Every requested step with a committed checkpoint is up to date; "
21066 "the postprocessor is not launched.")
21067 else:
21068 print("[INFO] No requested step has a committed checkpoint yet; the "
21069 "postprocessor is not launched.")
21070 missing_files = diagnostic.get('missing_files_for_first_incomplete_step') or []
21071 if first_incomplete is not None and missing_files:
21072 print(f"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
21073 if first_incomplete is not None:
21074 print(
21075 "[INFO] Closest committed source steps: "
21076 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
21077 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
21078 )
21079 if post_finalize_command and post_plan['steps_current']:
21081 run_dir, post_plan['recipe_fingerprint'], post_finalize_command
21082 )
21083 else:
21084 post_effective_cfg = post_plan['effective_post_cfg']
21085 post_io_cfg = post_effective_cfg.get('io', {})
21086 try:
21087 output_dir_rel = post_io_cfg['output_directory']
21088 output_prefix = post_io_cfg['output_filename_prefix']
21089 except KeyError as e:
21090 print(f"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
21091 sys.exit(1)
21092
21093 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
21094 os.makedirs(output_dir_abs, exist_ok=True)
21095 print(f"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
21096 statistics_output_paths = get_post_statistics_output_artifacts(post_effective_cfg, run_dir, monitor_cfg)
21097 for stats_path in statistics_output_paths:
21098 print(f"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
21099
21100 source_files_post = {'Case': case_path, 'Post-Profile': args.post}
21101 post_decomposition = resolve_post_decomposition(
21102 run_dir, post_num_procs_effective, getattr(args, "decomposition", None),
21103 cluster_cfg if cluster_mode else None)
21104 if post_decomposition["problems"]:
21105 print(f"[FATAL] {' '.join(post_decomposition['problems'])}", file=sys.stderr)
21106 sys.exit(1)
21107 print(f"[INFO] Post-processing {describe_decomposition(post_decomposition, post_num_procs_effective)}")
21108 post_recipe_file = generate_post_recipe_file(run_dir, run_id, post_effective_cfg, source_files_post,
21109 monitor_cfg, post_decomposition)
21110 if post_exe is None:
21111 print(f"[FATAL] {post_exe_error}", file=sys.stderr)
21112 sys.exit(1)
21113 post_args = build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor") + [
21114 "-control_file",
21115 solver_control_path,
21116 "-postprocessing_config_file",
21117 post_recipe_file,
21118 ]
21119 post_stage_meta = {
21120 "num_procs_effective": post_num_procs_effective,
21121 "decomposition": _decomposition_record(post_decomposition),
21122 "steps_planned": len(post_plan['steps_to_process']),
21123 "steps_current": post_plan['steps_current'],
21124 "recompute": recompute,
21125 "source_frontier_step": post_plan['source_frontier_step'],
21126 "source_frontier_deferred": post_plan['source_frontier_deferred'],
21127 "recipe_fingerprint": post_plan['recipe_fingerprint'],
21128 "finalize_command": post_finalize_command,
21129 "submitted": False,
21130 }
21131 if cluster_mode:
21132 scheduler_dir = os.path.join(run_dir, "scheduler")
21133 os.makedirs(scheduler_dir, exist_ok=True)
21134 post_script = os.path.join(scheduler_dir, "post.sbatch")
21135 post_log = os.path.join(scheduler_dir, "post_%j.out")
21136 post_err = os.path.join(scheduler_dir, "post_%j.err")
21137 post_cluster_cfg = cluster_cfg
21138 raw_post_cmd = build_cluster_launch_command(
21139 post_cluster_cfg,
21140 post_exe,
21141 post_args,
21142 config_search_anchor=case_path,
21143 extra_search_anchors=[cluster_path],
21144 force_num_procs=post_num_procs_effective,
21145 )
21146 post_cmd, _ = build_post_locked_command(
21147 run_dir,
21148 post_plan['recipe_fingerprint'],
21149 raw_post_cmd,
21150 create_wrapper=True,
21151 success_command=post_finalize_command,
21152 post_recipe=archived_post_path,
21153 postprocessor=post_exe,
21154 recompute=recompute,
21155 )
21156 spectra_follow = []
21157 if 'spectra' in post_stages:
21158 follow = build_spectra_follow_command(run_dir, args.post, post_effective_cfg,
21159 recompute=recompute)
21160 if follow:
21161 spectra_follow = [follow]
21162 print("[INFO] Spectra will be measured in the post job, after "
21163 "the field post-processor completes.")
21165 post_script,
21166 f"{run_id}_post",
21167 post_cluster_cfg,
21168 post_cmd,
21169 run_dir,
21170 post_log,
21171 post_err,
21172 env_vars={"LOG_LEVEL": monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()},
21173 follow_commands=spectra_follow,
21174 identity_check=(post_exe, read_binary_build_identity(post_exe).get("version_line"),
21175 "postprocessor" in load_run_executables(run_dir)),
21176 )
21177 submission_meta["stages"]["post-process"] = {"script": post_script, **post_stage_meta}
21178 print(f"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
21179
21180 if not args.no_submit:
21181 dependency_job = None
21182 if args.solve:
21183 dependency_job = submission_meta.get("stages", {}).get("solve", {}).get("job_id")
21184 submit_info = submit_sbatch(post_script, dependency=dependency_job)
21185 submission_meta["stages"]["post-process"].update(submit_info)
21186 submission_meta["stages"]["post-process"]["submitted"] = True
21187 if dependency_job:
21188 submission_meta["stages"]["post-process"]["dependency"] = f"afterok:{dependency_job}"
21189 print(f"[SUCCESS] Submitted post job: {submit_info['job_id']}")
21190 stages_completed.append('post-process')
21191 else:
21192 raw_command = build_local_launch_command(
21193 post_exe,
21194 post_args,
21195 post_num_procs_effective,
21196 config_search_anchor=case_path,
21197 allow_single_rank_launcher_override=True,
21198 force_num_procs=post_num_procs_effective,
21199 )
21200 command, _ = build_post_locked_command(
21201 run_dir,
21202 post_plan['recipe_fingerprint'],
21203 raw_command,
21204 create_wrapper=True,
21205 success_command=post_finalize_command,
21206 post_recipe=archived_post_path,
21207 postprocessor=post_exe,
21208 recompute=recompute,
21209 )
21210 post_log = os.path.join("scheduler", f"{run_id}_{output_prefix}.log")
21211 submission_meta["stages"]["post-process"] = {
21212 "command": command,
21213 "command_string": format_command_for_display(command),
21214 "log_file": post_log,
21215 **post_stage_meta,
21216 }
21217 if args.no_submit:
21218 print(f"[SUCCESS] Staged local post command: {post_log}")
21219 else:
21220 execute_command(command, run_dir, post_log, monitor_cfg)
21221 submission_meta["stages"]["post-process"]["submitted"] = True
21222 submission_meta["stages"]["post-process"]["executed"] = True
21223 submission_meta["stages"]["post-process"]["completed_at"] = datetime.now().isoformat()
21224 stages_completed.append('post-process')
21225
21226 if run_dir:
21227 workspace_root = find_workspace_root(run_dir)
21228 manifest_inputs = {}
21229 if args.solve:
21230 manifest_inputs["case"] = _relative_to_workspace(args.case, workspace_root)
21231 manifest_inputs["solver"] = _relative_to_workspace(args.solver, workspace_root)
21232 manifest_inputs["monitor"] = _relative_to_workspace(args.monitor, workspace_root)
21233 if args.post_process:
21234 manifest_inputs["post"] = _relative_to_workspace(args.post, workspace_root)
21235 if cluster_mode:
21236 manifest_inputs["cluster"] = _relative_to_workspace(cluster_path, workspace_root)
21237 if submission_meta.get("stages"):
21238 # A stage this invocation did not stage keeps its record (a continued solve
21239 # leaves the run's earlier post job visible to cancel and summarize).
21240 submission_path = os.path.join(run_dir, "scheduler", "submission.json")
21241 for stage_name, stage_meta in ((_read_json_if_exists(submission_path) or {}).get("stages") or {}).items():
21242 submission_meta["stages"].setdefault(stage_name, stage_meta)
21243 write_json_file(submission_path, submission_meta)
21244 asset_lock = read_yaml_file(os.path.join(run_dir, "inputs", "assets.lock.yml")) \
21245 if os.path.isfile(os.path.join(run_dir, "inputs", "assets.lock.yml")) else {}
21246 manifest = build_run_manifest(
21247 run_dir,
21248 run_id,
21249 workspace_root=workspace_root,
21250 launch_mode="slurm" if cluster_mode else "local",
21251 num_procs=solver_num_procs_effective,
21252 post_num_procs=post_num_procs_effective,
21253 stages_requested={"solve": bool(args.solve), "post_process": bool(args.post_process)},
21254 stages_completed=stages_completed,
21255 inputs=manifest_inputs,
21256 asset_lock=asset_lock,
21257 submission=submission_meta,
21258 lineage=run_lineage,
21259 )
21260 write_json_file(os.path.join(run_dir, "manifest.json"), manifest)
21261
21262 if stages_completed:
21263 elapsed = time.time() - workflow_start
21264 mins, secs = divmod(int(elapsed), 60)
21265 hrs, mins = divmod(mins, 60)
21266 if hrs > 0:
21267 time_str = f"{hrs}h {mins}m {secs}s"
21268 elif mins > 0:
21269 time_str = f"{mins}m {secs}s"
21270 else:
21271 time_str = f"{secs}s"
21272
21273 print("\n" + "=" * 60)
21274 print(" RUN SUMMARY")
21275 print("=" * 60)
21276 print(f" Run ID : {run_id}")
21277 print(f" Run directory : {os.path.relpath(run_dir)}")
21278 # A staged or Slurm-submitted stage has not run yet when this prints.
21279 staged = bool(cluster_mode or args.no_submit)
21280 print(f" Wall-clock : {time_str}")
21281 stages_label = "Stages staged" if args.no_submit else "Stages submitted" if staged else "Stages"
21282 print(f" {stages_label:<16}: {', '.join(stages_completed)}")
21283 print(f" Launch mode : {'slurm' if cluster_mode else 'local'}")
21284 if args.solve:
21285 print(f" Solver MPI procs: {solver_num_procs_effective}")
21286 if args.post_process:
21287 print(f" Post MPI procs : {post_num_procs_effective}")
21288 if args.solve and configs:
21289 total_steps = configs['case'].get('run_control', {}).get('total_steps', '?')
21290 result_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS['output'])
21291 print(f" {'Steps planned' if staged else 'Steps run':<16}: {total_steps}")
21292 print(f" Solver output : {os.path.relpath(result_dir)}")
21293 if 'post-process' in stages_completed and output_dir_abs:
21294 print(f" Post output : {os.path.relpath(output_dir_abs)}")
21295 for stats_path in statistics_output_paths:
21296 print(f" Stats output : {os.path.relpath(stats_path)}")
21297 # Report the configured log directory, not the default. An authorized external
21298 # path is exactly the case where the two differ, and it is the case where the
21299 # reader most needs to know where the logs actually went.
21300 log_display = os.path.join(run_dir, CANONICAL_RUN_PATHS['logs'])
21301 relative_log = os.path.relpath(log_display)
21302 # A relative path that climbs out of the working directory is harder to read
21303 # than the absolute one it stands for.
21304 print(f" Logs : "
21305 f"{log_display if relative_log.startswith('..') else relative_log}")
21306 if cluster_mode or submission_meta.get("stages"):
21307 submission_file = os.path.join(run_dir, "scheduler", "submission.json")
21308 print(f" Submission meta : {os.path.relpath(submission_file)}")
21309 print("=" * 60)
21310
21311
21312def parse_case_index_tsv(tsv_path: str) -> list:
21313 """!
21314 @brief Parse a case_index.tsv file back into a list of case entry dicts.
21315 @param[in] tsv_path Path to the case_index.tsv file.
21316 @return List of dicts with keys: index, case_id, run_dir, control_file,
21317 post_recipe_file, log_level, post_prefix.
21318 """
21319 entries = []
21320 with open(tsv_path) as f:
21321 for line in f:
21322 line = line.strip()
21323 if not line:
21324 continue
21325 parts = line.split("\t")
21326 entries.append({
21327 "index": int(parts[0]),
21328 "case_id": parts[1],
21329 "run_dir": parts[2],
21330 "control_file": parts[3],
21331 "post_recipe_file": parts[4],
21332 "log_level": parts[5],
21333 "post_prefix": parts[6],
21334 })
21335 return entries
21336
21337
21338def resolve_sweep_stage_executables(run_dirs: list) -> dict:
21339 """!
21340 @brief Choose the executables an array stage launches for every member of a study.
21341
21342 @details Array tasks share one script, which reads each member's `RUN_DIR` from the
21343 case index, so pinned members launch their own copy through the same
21344 `$RUN_DIR`-relative path. Members must agree: a study half pinned, or pinned
21345 at different paths, has no single command that launches each member's build.
21346 @param[in] run_dirs Member run directories the stage covers.
21347 @return Mapping of executable name to `(path token, staged --version line or None)`.
21348 @throws ValueError when members disagree about pinning.
21349 """
21350 pins = [load_run_executables(run_dir) for run_dir in run_dirs]
21351 chosen = {}
21352 for name in RUN_EXECUTABLE_NAMES:
21353 entries = [pin.get(name) for pin in pins]
21354 if not any(entries):
21355 path = resolve_runtime_executable(name)
21356 chosen[name] = (path, read_binary_build_identity(path).get("version_line"))
21357 continue
21358 relatives = {
21359 os.path.relpath(entry["path"], run_dir).replace(os.sep, "/") if entry else None
21360 for entry, run_dir in zip(entries, run_dirs)
21361 }
21362 lines = {entry.get("version_line") if entry else None for entry in entries}
21363 if None in relatives or len(relatives) != 1:
21364 raise ValueError(
21365 f"Study members disagree about the {name} they are pinned to; one array "
21366 "script cannot launch each member's build. Restage the study."
21367 )
21368 chosen[name] = (f"$RUN_DIR/{relatives.pop()}", lines.pop() if len(lines) == 1 else None)
21369 return chosen
21370
21371
21373 """!
21374 @brief Study/sweep orchestration using Slurm job arrays.
21375 @param[in] args Command-line style argument list supplied to the function.
21376 """
21377 study_path = os.path.abspath(args.study)
21378 cluster_path = os.path.abspath(args.cluster)
21379 workspace_root = find_workspace_root(study_path, cluster_path, os.getcwd())
21380 if workspace_root:
21381 enforce_workspace_version(workspace_root)
21382 enforce_reproducibility_policy(workspace_root)
21383
21384 study_cfg = read_yaml_file(study_path)
21385 cluster_cfg = read_yaml_file(cluster_path)
21386 validate_study_config(study_cfg, study_path)
21387 validate_cluster_config(cluster_cfg, cluster_path)
21388
21389 study_name = case_run_label(study_cfg, study_path)
21390 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
21391 study_id = f"{study_name}_{timestamp}"
21392 study_dir = os.path.join(workspace_artifact_root(workspace_root, "studies"), study_id)
21393 cases_dir = os.path.join(study_dir, "cases")
21394 scheduler_dir = os.path.join(study_dir, "scheduler")
21395 results_dir = os.path.join(study_dir, "output", "analysis")
21396 for path in [cases_dir, scheduler_dir, results_dir, os.path.join(study_dir, "logs")]:
21397 os.makedirs(path, exist_ok=True)
21398
21399 print(f"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
21400 base_cfgs = study_cfg["base_configs"]
21401 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
21402 base_snapshot_dir = os.path.join(study_dir, "base_configs")
21403 os.makedirs(base_snapshot_dir, exist_ok=True)
21404 portable_study_cfg = copy.deepcopy(study_cfg)
21405 portable_study_cfg["base_configs"] = {}
21406 for role in ("case", "solver", "monitor", "post"):
21407 snapshot_name = f"{role}.yml"
21408 snapshot_path = os.path.join(base_snapshot_dir, snapshot_name)
21409 shutil.copy2(base_paths[role], snapshot_path)
21410 portable_study_cfg["base_configs"][role] = os.path.join("base_configs", snapshot_name)
21411 shutil.copy2(study_path, os.path.join(study_dir, "study.source.yml"))
21412 write_yaml_file(os.path.join(study_dir, "study.yml"), portable_study_cfg)
21413 shutil.copy2(cluster_path, os.path.join(study_dir, "cluster.yml"))
21414
21415 base_case = read_yaml_file(base_paths["case"])
21416 base_solver = read_yaml_file(base_paths["solver"])
21417 base_monitor = read_yaml_file(base_paths["monitor"])
21418 base_post = read_yaml_file(base_paths["post"])
21419 validate_simulation_configs(base_case, base_solver, base_monitor, base_paths["case"], base_paths["solver"], base_paths["monitor"])
21420 validate_post_config(base_post, base_paths["post"], base_monitor, base_case)
21421
21422 combinations = expand_study_parameter_combinations(study_cfg)
21423 if not combinations:
21424 print("[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
21425 sys.exit(1)
21426 print(f"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
21427
21428 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
21429 case_entries = []
21430 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
21431
21432 # One array script launches every member, so the study runs on one PetscInt width: the
21433 # widest any member needs, unless one was requested.
21434 member_widths = []
21435 for combo in combinations:
21436 member = {"case": copy.deepcopy(base_case), "monitor": copy.deepcopy(base_monitor),
21437 "solver": {}, "post": {}}
21438 for full_key, value in combo.items():
21439 root, nested = full_key.split(".", 1)
21440 _deep_set(member[root], nested, value)
21441 absolutize_case_external_paths(member["case"], base_paths["case"])
21442 member_widths.append(required_petsc_index_bytes(member["case"], base_paths["case"], member["monitor"]))
21443 try:
21444 requested = requested_petsc_index_bytes(args)
21445 widest = max(member_widths, default=(4, "particle and grid fields fit 32-bit indices"))
21446 if requested == 4 and widest[0] == 8:
21447 raise ValueError(f"--petsc-index-bits 32 cannot run every study member: {widest[1]}.")
21448 if requested is not None:
21449 select_petsc_index_bytes(requested, f"{8 * requested}-bit indices were requested")
21450 else:
21452 except ValueError as exc:
21453 print(f"[FATAL] {exc}", file=sys.stderr)
21454 sys.exit(1)
21455
21456 for idx, combo in enumerate(combinations):
21457 case_id = f"case_{idx:04d}"
21458 run_dir = os.path.join(cases_dir, case_id)
21459 config_dir = os.path.join(run_dir, "config")
21460 ensure_run_layout(run_dir)
21461
21462 case_cfg = copy.deepcopy(base_case)
21463 solver_cfg = copy.deepcopy(base_solver)
21464 monitor_cfg = copy.deepcopy(base_monitor)
21465 post_cfg = copy.deepcopy(base_post)
21466 target_map = {"case": case_cfg, "solver": solver_cfg, "monitor": monitor_cfg, "post": post_cfg}
21467 for full_key, value in combo.items():
21468 root, nested = full_key.split(".", 1)
21469 _deep_set(target_map[root], nested, value)
21470
21471 # Preserve file-based/grid-gen workflows when study cases are materialized
21472 # into new directories by rewriting external paths as absolute.
21473 absolutize_case_external_paths(case_cfg, base_paths["case"])
21474
21475 case_path = os.path.join(config_dir, "case.yml")
21476 solver_path = os.path.join(config_dir, "solver.yml")
21477 monitor_path = os.path.join(config_dir, "monitor.yml")
21478 post_path = os.path.join(config_dir, "post.yml")
21479 write_yaml_file(case_path, case_cfg)
21480 write_yaml_file(solver_path, solver_cfg)
21481 write_yaml_file(monitor_path, monitor_cfg)
21482 write_yaml_file(post_path, post_cfg)
21483 write_json_file(os.path.join(config_dir, "active.json"), {
21484 "schema_version": 1,
21485 "revision": "initial",
21486 "updated_at": datetime.now().astimezone().isoformat(),
21487 "files": {
21488 "case": "config/case.yml",
21489 "solver": "config/solver.yml",
21490 "monitor": "config/monitor.yml",
21491 },
21492 })
21494 if resolve_executable_pinning(args, workspace_root):
21495 try:
21496 pin_run_executables(run_dir, "initial")
21497 except ValueError as exc:
21498 print(f"[FATAL] {exc}", file=sys.stderr)
21499 sys.exit(1)
21500
21501 validate_simulation_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
21502 validate_post_config(post_cfg, post_path, monitor_cfg, case_cfg)
21503
21504 try:
21505 asset_lock = materialize_run_assets(
21506 run_dir, case_cfg, case_path, skip_kinds=unused_asset_kinds(case_cfg, solver_cfg)
21507 )
21508 except ValueError as exc:
21509 print(f"[FATAL] Study case {case_id}: {exc}", file=sys.stderr)
21510 sys.exit(1)
21511
21512 source_files = {'Case': case_path, 'Solver': solver_path, 'Monitor': monitor_path}
21513 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
21514 configs = {
21515 "case": case_cfg, "case_path": case_path,
21516 "solver": solver_cfg, "solver_path": solver_path,
21517 "monitor": monitor_cfg, "monitor_path": monitor_path,
21518 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
21519 "cluster_cfg": cluster_cfg,
21520 }
21521 control_file = generate_solver_control_file(run_dir, case_id, configs, cluster_tasks, monitor_files)
21523 run_dir,
21524 [control_file, monitor_files.get("whitelist"), monitor_files.get("profile"),
21525 *glob.glob(os.path.join(config_dir, "bcs*.run"))],
21526 )
21527
21528 source_dir = resolve_post_source_directory(run_dir, monitor_cfg, post_cfg, strict=False)
21529 if not isinstance(post_cfg.get('source_data'), dict):
21530 post_cfg['source_data'] = {}
21531 post_cfg['source_data']['directory'] = source_dir
21532 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
21533 for relative in (
21534 post_cfg["_picurv_paths"]["visualization"],
21535 post_cfg["_picurv_paths"]["statistics"],
21536 post_cfg["_picurv_paths"]["spectra"],
21537 ):
21538 os.makedirs(os.path.join(run_dir, relative), exist_ok=True)
21539 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix", "post")
21540 post_decomposition = resolve_post_decomposition(run_dir, cluster_tasks, None, cluster_cfg)
21541 if post_decomposition["problems"]:
21542 print(f"[FATAL] Study case {case_id}: {' '.join(post_decomposition['problems'])}", file=sys.stderr)
21543 sys.exit(1)
21544 post_recipe = generate_post_recipe_file(run_dir, case_id, post_cfg, {'Case': case_path, 'Post-Profile': post_path},
21545 monitor_cfg, post_decomposition)
21547
21548 case_entries.append({
21549 "index": idx,
21550 "case_id": case_id,
21551 "run_dir": os.path.abspath(run_dir),
21552 "control_file": control_file,
21553 "post_recipe_file": post_recipe,
21554 "log_level": str(monitor_cfg.get("logging", {}).get("verbosity", "INFO")).upper(),
21555 "post_prefix": output_prefix,
21556 "solve_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "Solver")),
21557 "post_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor")),
21558 "parameters": combo,
21559 })
21561 os.path.join(run_dir, "manifest.json"),
21563 run_dir, case_id, workspace_root=workspace_root, launch_mode="slurm",
21564 artifact_type="study-case", study_id=study_id, case_id=case_id,
21565 num_procs=cluster_tasks, post_num_procs=cluster_tasks,
21566 stages_requested={"solve": True, "post_process": True},
21567 inputs={
21568 "case": _relative_to_workspace(case_path, workspace_root),
21569 "solver": _relative_to_workspace(solver_path, workspace_root),
21570 "monitor": _relative_to_workspace(monitor_path, workspace_root),
21571 "post": _relative_to_workspace(post_path, workspace_root),
21572 },
21573 asset_lock=asset_lock,
21574 ),
21575 )
21576
21577 with open(case_index_file, "w") as f:
21578 for entry in case_entries:
21579 f.write(
21580 "\t".join(
21581 [
21582 str(entry["index"]),
21583 entry["case_id"],
21584 entry["run_dir"],
21585 entry["control_file"],
21586 entry["post_recipe_file"],
21587 entry["log_level"],
21588 entry["post_prefix"],
21589 entry["solve_diagnostic_args"],
21590 entry["post_diagnostic_args"],
21591 ]
21592 ) + "\n"
21593 )
21594 print(f"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
21595
21596 max_idx = len(case_entries) - 1
21597 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
21598 array_spec = f"0-{max_idx}"
21599 if max_conc:
21600 array_spec = f"{array_spec}%{max_conc}"
21601
21602 stage_executables = resolve_sweep_stage_executables([entry["run_dir"] for entry in case_entries])
21603 solver_exe, solver_version_line = stage_executables["simulator"]
21604 post_exe, post_version_line = stage_executables["postprocessor"]
21605 solver_array_script = os.path.join(scheduler_dir, "solver_array.sbatch")
21606 post_array_script = os.path.join(scheduler_dir, "post_array.sbatch")
21608 solver_array_script,
21609 f"{study_id}_solve",
21610 cluster_cfg,
21611 array_spec,
21612 case_index_file,
21613 "solve",
21614 solver_exe,
21615 post_exe,
21616 os.path.join(scheduler_dir, "solver_%A_%a.out"),
21617 os.path.join(scheduler_dir, "solver_%A_%a.err"),
21618 expected_version_line=solver_version_line,
21619 )
21621 post_array_script,
21622 f"{study_id}_post",
21623 cluster_cfg,
21624 array_spec,
21625 case_index_file,
21626 "post",
21627 solver_exe,
21628 post_exe,
21629 os.path.join(scheduler_dir, "post_%A_%a.out"),
21630 os.path.join(scheduler_dir, "post_%A_%a.err"),
21631 expected_version_line=post_version_line,
21632 )
21633 print(f"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
21634
21635 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
21636 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_aggregate.sbatch")
21638 metrics_aggregate_script,
21639 f"{study_id}_metrics",
21640 cluster_cfg,
21641 study_dir,
21642 picurv_path,
21643 )
21644 print(f"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
21645
21646 submission = {
21647 "launch_mode": "slurm",
21648 "study_id": study_id,
21649 "solver_array": {"script": solver_array_script, "submitted": False},
21650 "post_array": {"script": post_array_script, "submitted": False},
21651 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
21652 "no_submit": bool(args.no_submit),
21653 }
21654 if not args.no_submit:
21655 solver_submit = submit_sbatch(solver_array_script)
21656 submission["solver_array"].update(solver_submit)
21657 submission["solver_array"]["submitted"] = True
21658 post_submit = submit_sbatch(post_array_script, dependency=solver_submit["job_id"])
21659 submission["post_array"].update(post_submit)
21660 submission["post_array"]["submitted"] = True
21661 submission["post_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
21662 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
21663 submission["metrics_aggregate"].update(metrics_submit)
21664 submission["metrics_aggregate"]["submitted"] = True
21665 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
21666 print(f"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
21667 print(f"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
21668 print(f"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
21669
21670 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
21671 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
21672
21673 summary = {
21674 "schema_version": 2,
21675 "artifact_type": "study",
21676 "study_id": study_id,
21677 "created_at": datetime.now().isoformat(),
21678 "software": dict(picurv_build()),
21679 "study_type": study_cfg.get("study_type"),
21680 "num_cases": len(case_entries),
21681 "paths": {
21682 "study_dir": study_dir,
21683 "case_index": case_index_file,
21684 "solver_array_script": solver_array_script,
21685 "post_array_script": post_array_script,
21686 "metrics_table": metrics_csv,
21687 "plots_dir": os.path.join(results_dir, "plots"),
21688 },
21689 "submission": submission,
21690 }
21691 write_json_file(os.path.join(scheduler_dir, "submission.json"), submission)
21692 write_json_file(os.path.join(study_dir, "study_manifest.json"), summary)
21693 write_json_file(os.path.join(results_dir, "summary.json"), {"study_id": study_id, "metrics_csv": metrics_csv, "plots": plots})
21694
21695 print("\n" + "=" * 60)
21696 print(" STUDY SUMMARY")
21697 print("=" * 60)
21698 print(f" Study ID : {study_id}")
21699 print(f" Study directory : {os.path.relpath(study_dir)}")
21700 print(f" Cases generated : {len(case_entries)}")
21701 print(f" Array spec : {array_spec}")
21702 print(f" Solver script : {os.path.relpath(solver_array_script)}")
21703 print(f" Post script : {os.path.relpath(post_array_script)}")
21704 if args.no_submit:
21705 print(f" Submit with : picurv submit --study-dir {os.path.relpath(study_dir)}")
21706 if metrics_csv:
21707 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
21708 if plots:
21709 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
21710 print("=" * 60)
21711
21712
21714 """!
21715 @brief Continue a partially-completed Slurm parameter sweep study.
21716 @details Detects incomplete cases, prepares them for continuation (updating
21717 start_step, populating restart directories, regenerating control files),
21718 and submits new solver/post/metrics Slurm jobs. If all cases are already
21719 complete, performs metrics aggregation automatically.
21720 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
21721 """
21722 study_dir = os.path.abspath(args.study_dir)
21723 manifest_path = os.path.join(study_dir, "study_manifest.json")
21724 if not os.path.isfile(manifest_path):
21725 emit_structured_error(ERROR_CODE_CFG_FILE_NOT_FOUND, key="study_dir", file_path=manifest_path,
21726 message="Study manifest not found.", hint=_STUDY_DIR_HINT)
21727 sys.exit(1)
21728 adopt_relocated_artifact(study_dir, kind="study")
21729 manifest = _read_json_if_exists(manifest_path)
21730 study_id = manifest["study_id"]
21731
21732 study_path = os.path.join(study_dir, "study.yml")
21733 cluster_path = os.path.abspath(args.cluster) if args.cluster else os.path.join(study_dir, "cluster.yml")
21734 study_cfg = read_yaml_file(study_path)
21735 cluster_cfg = read_yaml_file(cluster_path)
21736 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
21737 validate_cluster_config(cluster_cfg, cluster_path)
21738
21739 if args.cluster:
21740 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir, "cluster.yml"))
21741 print(f"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
21742
21743 scheduler_dir = os.path.join(study_dir, "scheduler")
21744 cases_dir = os.path.join(study_dir, "cases")
21745 results_dir = os.path.join(study_dir, "output", "analysis")
21746 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
21747 if not os.path.isfile(case_index_file):
21748 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
21749 sys.exit(1)
21750
21751 parsed_entries = parse_case_index_tsv(case_index_file)
21752
21753 cold_cases = cold_study_members(study_dir)
21754 if cold_cases and getattr(args, "auto_fetch", False):
21755 print(
21756 "[INFO] --auto-fetch: restoring cold-storage member(s) before continuing: "
21757 + ", ".join(cold_cases)
21758 )
21759 try:
21760 restore_cold_study_members(study_dir, cold_cases)
21761 except StorageError as exc:
21762 print(f"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
21763 sys.exit(1)
21764 cold_cases = cold_study_members(study_dir)
21765 if cold_cases:
21766 print(
21767 "[FATAL] Study continuation requires payload from cold-storage member(s): "
21768 + ", ".join(cold_cases),
21769 file=sys.stderr,
21770 )
21771 for case_id in cold_cases:
21772 state = storage_state_summary(os.path.join(cases_dir, case_id))
21773 print(
21774 f" Restore {case_id} with: picurv storage restore --archive-id "
21775 f"{state.get('archive_id') or '<archive-id>'}",
21776 file=sys.stderr,
21777 )
21778 print(
21779 " Or pass --auto-fetch to restore them automatically.",
21780 file=sys.stderr,
21781 )
21782 sys.exit(1)
21783
21784 # Staging writes these as snapshots inside the study (base_configs/<role>.yml), so
21785 # they resolve against the study directory, not the workspace that holds it.
21786 base_cfgs = study_cfg["base_configs"]
21787 base_paths = {k: os.path.join(study_dir, v) for k, v in base_cfgs.items()}
21788 base_case = read_yaml_file(base_paths["case"])
21789
21790 combinations = expand_study_parameter_combinations(study_cfg)
21791 if len(combinations) != len(parsed_entries):
21792 print(
21793 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
21794 f"case_index.tsv ({len(parsed_entries)} entries).",
21795 file=sys.stderr,
21796 )
21797 sys.exit(1)
21798
21799 print(f"\n[INFO] Study: {study_id}")
21800 print(f"[INFO] Scanning {len(combinations)} case(s) for completion status...")
21801
21802 incomplete_indices = []
21803 all_case_entries = []
21804 for idx, combo in enumerate(combinations):
21805 case_id = f"case_{idx:04d}"
21806 entry = parsed_entries[idx]
21807 entry["parameters"] = combo
21808 run_dir = entry["run_dir"]
21809
21810 effective_case = copy.deepcopy(base_case)
21811 for full_key, value in combo.items():
21812 root, nested = full_key.split(".", 1)
21813 if root == "case":
21814 _deep_set(effective_case, nested, value)
21815 try:
21816 eff_start = int(effective_case.get("run_control", {}).get("start_step", 0) or 0)
21817 except (TypeError, ValueError):
21818 eff_start = 0
21819 eff_total = int(effective_case["run_control"]["total_steps"])
21820 target = eff_start + eff_total
21821
21822 monitor_cfg = read_yaml_file(os.path.join(run_dir, "config", "monitor.yml"))
21823 status = detect_case_completion_status(run_dir, monitor_cfg, target)
21824 entry["_status"] = status
21825
21826 if status["status"] == "complete":
21827 print(f" {case_id}: complete (step {status['last_step']}/{target})")
21828 elif status["status"] == "partial":
21829 print(f" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
21830 incomplete_indices.append(idx)
21831 else:
21832 print(f" {case_id}: no checkpoint — will re-run from scratch")
21833 incomplete_indices.append(idx)
21834
21835 all_case_entries.append(entry)
21836
21837 if not incomplete_indices:
21838 print("\n[INFO] All cases are complete. Running metrics aggregation...")
21839 metrics_csv = aggregate_study_metrics(study_cfg, all_case_entries, results_dir)
21840 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
21841 print("\n" + "=" * 60)
21842 print(" STUDY CONTINUATION SUMMARY")
21843 print("=" * 60)
21844 print(f" Study ID : {study_id}")
21845 print(f" Status : ALL COMPLETE")
21846 if metrics_csv:
21847 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
21848 if plots:
21849 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
21850 print("=" * 60)
21851 return
21852
21853 print(f"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
21854
21855 skipped = []
21856 for idx in incomplete_indices:
21857 entry = all_case_entries[idx]
21858 status = entry["_status"]
21859 if status["status"] == "partial":
21861 entry["run_dir"], entry["case_id"],
21862 status["last_step"], status["target_step"],
21863 cluster_cfg,
21864 )
21865 elif status["status"] == "empty":
21866 print(f"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
21867
21868 solver_array_spec = ",".join(str(i) for i in incomplete_indices)
21869 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
21870 if max_conc:
21871 solver_array_spec = f"{solver_array_spec}%{max_conc}"
21872
21873 max_idx = len(combinations) - 1
21874 post_array_spec = f"0-{max_idx}"
21875 if max_conc:
21876 post_array_spec = f"{post_array_spec}%{max_conc}"
21877
21878 try:
21879 stage_executables = resolve_sweep_stage_executables(
21880 [entry["run_dir"] for entry in all_case_entries]
21881 )
21882 except ValueError as exc:
21883 print(f"[FATAL] {exc}", file=sys.stderr)
21884 sys.exit(1)
21885 solver_exe, solver_version_line = stage_executables["simulator"]
21886 post_exe, post_version_line = stage_executables["postprocessor"]
21887 # The post array relaunches every member through the lock wrapper; a study staged
21888 # by an earlier version holds a wrapper without the launch-time planning it needs.
21889 for entry in all_case_entries:
21890 ensure_post_lock_wrapper(entry["run_dir"])
21891
21892 solver_continue_script = os.path.join(scheduler_dir, "solver_continue_array.sbatch")
21893 post_continue_script = os.path.join(scheduler_dir, "post_continue_array.sbatch")
21895 solver_continue_script,
21896 f"{study_id}_solve_cont",
21897 cluster_cfg,
21898 solver_array_spec,
21899 case_index_file,
21900 "solve",
21901 solver_exe, post_exe,
21902 os.path.join(scheduler_dir, "solver_cont_%A_%a.out"),
21903 os.path.join(scheduler_dir, "solver_cont_%A_%a.err"),
21904 expected_version_line=solver_version_line,
21905 )
21907 post_continue_script,
21908 f"{study_id}_post_cont",
21909 cluster_cfg,
21910 post_array_spec,
21911 case_index_file,
21912 "post",
21913 solver_exe, post_exe,
21914 os.path.join(scheduler_dir, "post_cont_%A_%a.out"),
21915 os.path.join(scheduler_dir, "post_cont_%A_%a.err"),
21916 expected_version_line=post_version_line,
21917 )
21918
21919 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
21920 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_continue_aggregate.sbatch")
21922 metrics_aggregate_script,
21923 f"{study_id}_metrics_cont",
21924 cluster_cfg,
21925 study_dir,
21926 picurv_path,
21927 )
21928 print(f"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
21929
21930 submission = {
21931 "launch_mode": "slurm",
21932 "study_id": study_id,
21933 "continuation": True,
21934 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
21935 "solver_continue_array": {"script": solver_continue_script, "submitted": False},
21936 "post_continue_array": {"script": post_continue_script, "submitted": False},
21937 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
21938 "no_submit": bool(args.no_submit),
21939 }
21940 if not args.no_submit:
21941 solver_submit = submit_sbatch(solver_continue_script)
21942 submission["solver_continue_array"].update(solver_submit)
21943 submission["solver_continue_array"]["submitted"] = True
21944 post_submit = submit_sbatch(post_continue_script, dependency=solver_submit["job_id"])
21945 submission["post_continue_array"].update(post_submit)
21946 submission["post_continue_array"]["submitted"] = True
21947 submission["post_continue_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
21948 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
21949 submission["metrics_aggregate"].update(metrics_submit)
21950 submission["metrics_aggregate"]["submitted"] = True
21951 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
21952 print(f"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
21953 print(f"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
21954 print(f"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
21955
21956 write_json_file(os.path.join(scheduler_dir, "submission_continue.json"), submission)
21957
21958 manifest["continuation"] = {
21959 "continued_at": datetime.now().isoformat(),
21960 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
21961 "submission": submission,
21962 }
21963 write_json_file(manifest_path, manifest)
21964
21965 print("\n" + "=" * 60)
21966 print(" STUDY CONTINUATION SUMMARY")
21967 print("=" * 60)
21968 print(f" Study ID : {study_id}")
21969 print(f" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
21970 print(f" Solver array spec : {solver_array_spec}")
21971 print(f" Post array spec : {post_array_spec}")
21972 print(f" Solver script : {os.path.relpath(solver_continue_script)}")
21973 print(f" Post script : {os.path.relpath(post_continue_script)}")
21974 print(f" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
21975 if not args.no_submit:
21976 print(f" [Metrics aggregation will run automatically after post-processing]")
21977 else:
21978 print(f" [--no-submit] Scripts generated but not submitted. Submit them with:")
21979 print(f" picurv submit --study-dir {os.path.relpath(study_dir)}")
21980 print("=" * 60)
21981
21982
21984 """!
21985 @brief Re-run metrics aggregation and plot generation for an existing study.
21986 @param[in] args Parsed CLI arguments with study_dir.
21987 """
21988 study_dir = os.path.abspath(args.study_dir)
21989 study_path = os.path.join(study_dir, "study.yml")
21990 if not os.path.isfile(study_path):
21991 emit_structured_error(ERROR_CODE_CFG_FILE_NOT_FOUND, key="study_dir", file_path=study_path,
21992 message="Study config not found.", hint=_STUDY_DIR_HINT)
21993 sys.exit(1)
21994 adopt_relocated_artifact(study_dir, kind="study")
21995 study_cfg = read_yaml_file(study_path)
21996 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
21997
21998 case_index_file = os.path.join(study_dir, "scheduler", "case_index.tsv")
21999 if not os.path.isfile(case_index_file):
22000 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
22001 sys.exit(1)
22002
22003 parsed_entries = parse_case_index_tsv(case_index_file)
22004 cold_cases = cold_study_members(study_dir)
22005 if cold_cases and getattr(args, "auto_fetch", False):
22006 print(
22007 "[INFO] --auto-fetch: restoring cold-storage member(s) before aggregating: "
22008 + ", ".join(cold_cases)
22009 )
22010 try:
22011 restore_cold_study_members(study_dir, cold_cases)
22012 except StorageError as exc:
22013 print(f"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
22014 sys.exit(1)
22015 cold_cases = cold_study_members(study_dir)
22016 if cold_cases:
22017 print(
22018 "[INFO] Cold-storage member(s) cannot be re-measured; their previously "
22019 "aggregated values are carried forward: " + ", ".join(cold_cases)
22020 )
22021 combinations = expand_study_parameter_combinations(study_cfg)
22022 if len(combinations) != len(parsed_entries):
22023 print(
22024 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
22025 f"case_index.tsv ({len(parsed_entries)} entries).",
22026 file=sys.stderr,
22027 )
22028 sys.exit(1)
22029
22030 case_entries = []
22031 for idx, combo in enumerate(combinations):
22032 entry = parsed_entries[idx]
22033 entry["parameters"] = combo
22034 case_entries.append(entry)
22035
22036 results_dir = os.path.join(study_dir, "output", "analysis")
22037 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
22038 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
22039
22040 print("\n" + "=" * 60)
22041 print(" REAGGREGATION SUMMARY")
22042 print("=" * 60)
22043 if metrics_csv:
22044 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
22045 if plots:
22046 print(f" Plots generated : {len(plots)}")
22047 print("=" * 60)
22048
22049
22050_SUMMARY_NUMERIC_RE = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
22051
22052
22053def _read_yaml_if_exists(filepath: str):
22054 """!
22055 @brief Read YAML when present, otherwise return None.
22056 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
22057 @return Value returned by `_read_yaml_if_exists()`.
22058 """
22059 if not filepath or not os.path.isfile(filepath):
22060 return None
22061 try:
22062 with open(filepath, "r", encoding="utf-8") as f:
22063 return yaml.safe_load(f)
22064 except yaml.YAMLError:
22065 return None
22066
22067
22068def _read_json_if_exists(filepath: str):
22069 """!
22070 @brief Read JSON when present, otherwise return None.
22071 @param[in] filepath Argument passed to `_read_json_if_exists()`.
22072 @return Value returned by `_read_json_if_exists()`.
22073 """
22074 if not filepath or not os.path.isfile(filepath):
22075 return None
22076 with open(filepath, "r", encoding="utf-8") as f:
22077 return json.load(f)
22078
22079
22081 """!
22082 @brief Best-effort integer parsing for summary extraction.
22083 @param[in] value Argument passed to `_parse_int_loose()`.
22084 @return Value returned by `_parse_int_loose()`.
22085 """
22086 if value is None:
22087 return None
22088 text = str(value).strip()
22089 if not text:
22090 return None
22091 try:
22092 return int(text)
22093 except ValueError:
22094 try:
22095 return int(float(text))
22096 except ValueError:
22097 return None
22098
22099
22101 """!
22102 @brief Best-effort float parsing for summary extraction.
22103 @param[in] value Argument passed to `_parse_float_loose()`.
22104 @return Value returned by `_parse_float_loose()`.
22105 """
22106 if value is None:
22107 return None
22108 text = str(value).strip()
22109 if not text:
22110 return None
22111 try:
22112 return float(text)
22113 except ValueError:
22114 return None
22115
22116
22118 """!
22119 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
22120 @param[in] text Argument passed to `_extract_numeric_tuple()`.
22121 @return Value returned by `_extract_numeric_tuple()`.
22122 """
22123 if not text:
22124 return []
22125 return [float(token) for token in _SUMMARY_NUMERIC_RE.findall(text)]
22126
22127
22128def _build_summary_context(run_dir: str) -> dict:
22129 """!
22130 @brief Resolve run-local config and artifact paths for summarize.
22131 @param[in] run_dir Argument passed to `_build_summary_context()`.
22132 @return Value returned by `_build_summary_context()`.
22133 """
22134 run_dir = os.path.abspath(run_dir)
22135 if not os.path.isdir(run_dir):
22137 ERROR_CODE_CFG_FILE_NOT_FOUND,
22138 key="run_dir",
22139 file_path=run_dir,
22140 message="Run directory not found.",
22141 )
22142 sys.exit(1)
22143
22144 config_dir = os.path.join(run_dir, "config")
22145 config_paths = {
22146 "case": os.path.join(config_dir, "case.yml"),
22147 "solver": os.path.join(config_dir, "solver.yml"),
22148 "monitor": os.path.join(config_dir, "monitor.yml"),
22149 }
22150 monitor_cfg = _read_yaml_if_exists(config_paths["monitor"]) or {}
22151 case_cfg = _read_yaml_if_exists(config_paths["case"]) or {}
22152 solver_cfg = _read_yaml_if_exists(config_paths["solver"]) or {}
22153 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
22154
22155 io_cfg = monitor_cfg.get("io", {}) if isinstance(monitor_cfg, dict) else {}
22156 scheduler_dir = os.path.join(run_dir, "scheduler")
22157
22158 profiling_cfg = {"mode": "off", "functions": [], "final_summary_enabled": True, "helpers": []}
22159 if monitor_cfg:
22160 profiling_cfg = resolve_profiling_config(monitor_cfg)
22161
22162 particle_console_output_freq = None
22163 particle_log_interval = None
22164 if monitor_cfg:
22165 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
22166 particle_log_interval = io_cfg.get("particle_log_interval")
22167
22168 particle_count_cfg = None
22169 if case_cfg:
22170 particle_count_cfg = (
22171 case_cfg.get("models", {})
22172 .get("physics", {})
22173 .get("particles", {})
22174 .get("count")
22175 )
22176
22177 return {
22178 "run_dir": run_dir,
22179 "config_dir": config_dir,
22180 "log_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"]),
22181 "metrics_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS["metrics"]),
22182 "scheduler_dir": scheduler_dir,
22183 "monitor_cfg": monitor_cfg,
22184 "case_cfg": case_cfg,
22185 "solver_cfg": solver_cfg,
22186 "config_paths": config_paths,
22187 "manifest": manifest,
22188 "profiling_cfg": profiling_cfg,
22189 "particle_console_output_freq": particle_console_output_freq,
22190 "particle_log_interval": particle_log_interval,
22191 "particle_count_cfg": particle_count_cfg,
22192 }
22193
22194
22195def _require_summary_config(context: dict, name: str) -> dict:
22196 """!
22197 @brief Return one explicitly requested copied config or fail with a structured error.
22198 @param[in] context Summary context returned by `_build_summary_context()`.
22199 @param[in] name Config selector name.
22200 @return Parsed config mapping.
22201 """
22202 path = context["config_paths"][name]
22203 cfg = context.get(f"{name}_cfg")
22204 if not os.path.isfile(path):
22206 ERROR_CODE_CFG_FILE_NOT_FOUND,
22207 key=name,
22208 file_path=path,
22209 message=f"Copied run config '{name}.yml' was not found.",
22210 hint="Use a staged run directory containing the requested copied config.",
22211 )
22212 sys.exit(1)
22213 if not isinstance(cfg, dict) or not cfg:
22215 ERROR_CODE_CFG_INVALID_VALUE,
22216 key=name,
22217 file_path=path,
22218 message=f"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
22219 )
22220 sys.exit(1)
22221 return cfg
22222
22223
22224def _build_run_overview(context: dict) -> dict:
22225 """!
22226 @brief Build timestep-independent run metadata for summarize.
22227 @param[in] context Summary context returned by `_build_summary_context()`.
22228 @return Curated run metadata mapping.
22229 """
22230 manifest = context["manifest"]
22231 software = manifest.get("software") if isinstance(manifest.get("software"), dict) else {}
22232 return {
22233 "run_id": manifest.get("run_id", os.path.basename(context["run_dir"])),
22234 "run_dir": context["run_dir"],
22235 "created_at": manifest.get("created_at"),
22236 "launch_mode": manifest.get("launch_mode"),
22237 "release_version": software.get("release_version"),
22238 "build_id": software.get("build_id"),
22239 "git_commit": software.get("git_commit", manifest.get("git_commit")),
22240 "solver_num_procs": manifest.get("solver_num_procs", manifest.get("num_procs")),
22241 "post_num_procs": manifest.get("post_num_procs"),
22242 "stages_requested": manifest.get("stages_requested"),
22243 "stages_completed_or_submitted": manifest.get("stages_completed_or_submitted"),
22244 }
22245
22246
22247def _summarize_turbulence(turbulence_cfg: dict) -> dict:
22248 """!
22249 @brief Build compact turbulence and wall-model selections.
22250 @param[in] turbulence_cfg Case turbulence configuration mapping.
22251 @return Curated turbulence and wall-model mapping.
22252 """
22253 result = {}
22254 for key in ("les", "wall_function"):
22255 value = turbulence_cfg.get(key)
22256 if isinstance(value, dict):
22257 result[key] = {
22258 "enabled": value.get("enabled", True),
22259 "model": value.get("model"),
22260 **{k: v for k, v in value.items() if k not in {"enabled", "model"}},
22261 }
22262 elif value is not None:
22263 result[key] = value
22264 return result
22265
22266
22267def _build_case_overview(context: dict) -> dict:
22268 """!
22269 @brief Build a curated case.yml summary with useful derived quantities.
22270 @param[in] context Summary context returned by `_build_summary_context()`.
22271 @return Curated case configuration mapping.
22272 """
22273 cfg = _require_summary_config(context, "case")
22274 props = cfg.get("properties", {})
22275 scaling = props.get("scaling", {})
22276 fluid = props.get("fluid", {})
22277 run = cfg.get("run_control", {})
22278 grid = cfg.get("grid", {})
22279 models = cfg.get("models", {})
22280 domain = models.get("domain", {})
22281 physics = models.get("physics", {})
22282 particles = physics.get("particles", {})
22283 start = int(run.get("start_step", 0))
22284 total = int(run.get("total_steps", 0))
22285 dt = float(run.get("dt_physical", 0.0))
22286 length_ref = float(scaling.get("length_ref"))
22287 velocity_ref = float(scaling.get("velocity_ref"))
22288 density = float(fluid.get("density"))
22289 viscosity = float(fluid.get("viscosity"))
22291 first_block_faces = {row["face"]: row for row in prepared_bcs[0]}
22292 periodic_axes = {
22293 "i": first_block_faces["-Xi"]["type"] == "PERIODIC",
22294 "j": first_block_faces["-Eta"]["type"] == "PERIODIC",
22295 "k": first_block_faces["-Zeta"]["type"] == "PERIODIC",
22296 }
22297 bc_blocks = []
22298 for block_idx, block in enumerate(prepared_bcs):
22299 bc_blocks.append(
22300 {
22301 "block": block_idx,
22302 "faces": [
22303 {"face": row["face"], "type": row["type"], "handler": row["handler"]}
22304 for row in block
22305 ],
22306 }
22307 )
22308 return {
22309 "run_control": {
22310 "start_step": start,
22311 "total_steps": total,
22312 "end_step": start + total,
22313 "dt_physical": dt,
22314 "duration_physical": total * dt,
22315 "dt_nondimensional": to_solver_units(
22316 dt, ("case", "run_control", "dt_physical"),
22317 {"length_ref": length_ref, "velocity_ref": velocity_ref}),
22318 },
22319 "properties": {
22320 "length_ref": length_ref,
22321 "velocity_ref": velocity_ref,
22322 "density": density,
22323 "viscosity": viscosity,
22324 "reynolds_number": density * velocity_ref * length_ref / viscosity if viscosity else None,
22325 "initial_conditions": props.get("initial_conditions", {}),
22326 },
22327 "grid": {
22328 "mode": grid.get("mode"),
22329 "processor_layout": resolve_grid_da_processor_layout(grid),
22330 "programmatic_settings": grid.get("programmatic_settings") if grid.get("mode") == "programmatic_c" else None,
22331 "source_file": grid.get("source_file"),
22332 },
22333 "domain": {
22334 "blocks": domain.get("blocks", 1),
22335 "dimensionality": physics.get("dimensionality", "3D"),
22336 "periodic": periodic_axes,
22337 },
22338 "physics": {
22339 "fsi": physics.get("fsi", {}),
22340 "particles": particles,
22341 "turbulence": _summarize_turbulence(physics.get("turbulence", {}) or {}),
22342 },
22343 "boundary_conditions": bc_blocks,
22344 }
22345
22346
22347def _summary_scalar_transport(context: dict, solver_cfg: dict) -> dict:
22348 """!
22349 @brief Scalar-transport parameters as the run resolves them, wherever they are set.
22350 @param[in] context Summary context returned by `_build_summary_context()`.
22351 @param[in] solver_cfg Parsed solver configuration.
22352 @return Mapping of parameter name to value; empty when unresolvable.
22353 """
22354 try:
22355 return resolve_scalar_transport(context.get("case_cfg") or {}, solver_cfg)
22356 except ValueError:
22357 return {}
22358
22359
22360def _build_solver_overview(context: dict) -> dict:
22361 """!
22362 @brief Build a curated solver.yml summary with normalized selections.
22363 @param[in] context Summary context returned by `_build_summary_context()`.
22364 @return Curated solver configuration mapping.
22365 """
22366 cfg = _require_summary_config(context, "solver")
22367 strategy = cfg.get("strategy", {}) or {}
22368 selected = normalize_momentum_solver_type(strategy.get("momentum_solver", "Dual Time Picard Jameson RK"))
22369 momentum_cfg = cfg.get("momentum_solver", {}) or {}
22370 dualtime = momentum_cfg.get("dual_time_picard_jameson_rk", momentum_cfg.get("dual_time_picard_rk4", {})) or {}
22371 newton_krylov = momentum_cfg.get("newton_krylov", {}) or {}
22372 poisson = cfg.get("poisson_solver", cfg.get("pressure_solver", {})) or {}
22373 operation_mode = cfg.get("operation_mode", {}) or {}
22374 operation_mode = {
22375 **operation_mode,
22376 "eulerian_field_source": normalize_eulerian_field_source(operation_mode.get("eulerian_field_source", "solve")),
22377 }
22378 if operation_mode.get("analytical_type") is not None:
22379 operation_mode["analytical_type"] = normalize_analytical_type(operation_mode["analytical_type"])
22380 passthrough = cfg.get("petsc_passthrough_options", {}) or {}
22381 return {
22382 "operation_mode": operation_mode,
22383 "momentum": {
22384 "type": selected,
22385 "central_diff": bool(strategy.get("central_diff", False)),
22386 "tolerances": cfg.get("tolerances", {}),
22387 "controls": newton_krylov if selected == "newton_krylov" else dualtime,
22388 },
22389 "poisson": poisson,
22390 "interpolation": cfg.get("interpolation", {"method": "Trilinear"}),
22391 "scalar_transport": _summary_scalar_transport(context, cfg),
22392 "verification": cfg.get("verification", {}),
22393 "petsc_passthrough": {"count": len(passthrough), "options": sorted(passthrough.keys())},
22394 }
22395
22396
22397def _build_monitor_overview(context: dict) -> dict:
22398 """!
22399 @brief Build a curated monitor.yml summary with resolved defaults.
22400 @param[in] context Summary context returned by `_build_summary_context()`.
22401 @return Curated monitor configuration mapping.
22402 """
22403 cfg = _require_summary_config(context, "monitor")
22404 logging_cfg = cfg.get("logging", {}) or {}
22405 io_cfg = cfg.get("io", {}) or {}
22406 diagnostics = resolve_diagnostics_config(cfg, context["run_dir"], "Solver")
22407 monitoring_flags = resolve_solver_monitoring_flags(cfg)
22408 solution_monitoring = normalize_solution_monitoring_config(cfg)
22409 enabled_petsc = sorted(key for key, value in diagnostics["petsc"].items() if value not in (False, None))
22410 return {
22411 "logging": {
22412 "verbosity": logging_cfg.get("verbosity", "WARNING"),
22413 "enabled_functions": logging_cfg.get("enabled_functions", []),
22414 },
22415 "profiling": resolve_profiling_config(cfg),
22416 "diagnostics": {
22417 "enabled_petsc": enabled_petsc,
22418 "petsc": diagnostics["petsc"],
22419 "runtime_memory_log": diagnostics["runtime_memory_log"],
22420 },
22421 "io": {
22422 "data_output_frequency": io_cfg.get("data_output_frequency"),
22423 "particle_console_output_frequency": resolve_particle_console_output_frequency(io_cfg),
22424 "particle_log_interval": io_cfg.get("particle_log_interval"),
22425 "directories": {
22426 "output": CANONICAL_RUN_PATHS["output"],
22427 "restart": CANONICAL_RUN_PATHS["restart"],
22428 "logs": CANONICAL_RUN_PATHS["logs"],
22429 "analysis": CANONICAL_RUN_PATHS["analysis"],
22430 },
22431 },
22432 "solver_monitoring": {
22433 "enabled_flags": sorted(flag for flag, value in monitoring_flags.items() if value not in (False, None)),
22434 "flags": monitoring_flags,
22435 },
22436 "solution_monitoring": solution_monitoring,
22437 }
22438
22439
22440def _parse_continuity_metrics_log(log_dir: str) -> "tuple[dict, list[int]]":
22441 """!
22442 @brief Read the continuity record into rows by step (one per block) plus observed order.
22443 @param[in] log_dir Run log directory.
22444 @return Mapping step -> list of block rows, and the order steps first appeared.
22445 """
22446 rows_by_step, step_order = {}, []
22447 for _, row in _continuity_rows(log_dir)[1]:
22448 step = row["step"]
22449 if step not in rows_by_step:
22450 step_order.append(step)
22451 rows_by_step[step] = {}
22452 rows_by_step[step][row["block"]] = {key: value for key, value in row.items() if key != "step"}
22453 return {step: list(blocks.values()) for step, blocks in rows_by_step.items()}, step_order
22454
22455
22456def _parse_particle_metrics_log(log_dir: str, metrics_dir: str) -> "tuple[dict, list[int]]":
22457 """!
22458 @brief Read the per-step particle record into rows by step plus observed order.
22459 @param[in] log_dir Run log directory.
22460 @param[in] metrics_dir Run analysis metrics directory.
22461 @return Mapping step -> row, and the steps in file order.
22462 """
22463 rows_by_step, step_order = {}, []
22464 for _, row in _particle_step_rows(log_dir, metrics_dir)[1]:
22465 rows_by_step[row["step"]] = {key: value for key, value in row.items() if key != "step"}
22466 step_order.append(row["step"])
22467 return rows_by_step, step_order
22468
22469
22470def _parse_momentum_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
22471 """!
22472 @brief Read the per-block momentum records (Picard history, Newton summary) into rows by step.
22473 @param[in] log_dir Run log directory.
22474 @return Rows by step and block, source path per block, and step order.
22475 """
22476 rows_by_step, sources, step_order = {}, {}, []
22477 # Per (step, block): accepted/rejected trial counts and the last committed state.
22478 state = {}
22479 for block, path, rows in _picard_history_rows(log_dir):
22480 sources[block] = path
22481 for _, row in rows:
22482 step = row["step"]
22483 step_order.append(step)
22484 entry = state.setdefault((step, block), {"accepted_count": 0, "rejected_count": 0,
22485 "last_accepted": None, "last_rejected": None})
22486 display = {key: value for key, value in row.items() if key != "step"}
22487 if row.get("status") == "accepted":
22488 entry["accepted_count"] += 1
22489 entry["last_accepted"] = display
22490 else:
22491 entry["rejected_count"] += 1
22492 entry["last_rejected"] = display
22493 for (step, block), entry in state.items():
22494 display_row = entry["last_accepted"] or entry["last_rejected"]
22495 if display_row:
22496 rows_by_step.setdefault(step, {})[block] = {
22497 **display_row,
22498 "accepted_count": entry["accepted_count"],
22499 "rejected_count": entry["rejected_count"],
22500 }
22501 for file_block, path, rows in _newton_summary_rows(log_dir):
22502 sources[file_block] = path
22503 for _, row in rows:
22504 step_order.append(row["step"])
22505 rows_by_step.setdefault(row["step"], {})[row["block"]] = {
22506 key: value for key, value in row.items() if key != "step"}
22507 return rows_by_step, sources, step_order
22508
22509
22510def _parse_poisson_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
22511 """!
22512 @brief Read the per-block Poisson histories into each step's last iteration.
22513 @param[in] log_dir Run log directory.
22514 @return Rows by step and block, source path per block, and step order.
22515 """
22516 rows_by_step, sources, step_order = {}, {}, []
22517 for block, path, rows in _poisson_history_rows(log_dir):
22518 sources[block] = path
22519 for _, row in rows:
22520 step_order.append(row["step"])
22521 rows_by_step.setdefault(row["step"], {})[block] = {
22522 key: value for key, value in row.items() if key not in ("step",)}
22523 return rows_by_step, sources, step_order
22524
22525
22526def _parse_profiling_timestep_csv(filepath: str) -> "tuple[dict, list[int]]":
22527 """!
22528 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
22529 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
22530 @return Value returned by `_parse_profiling_timestep_csv()`.
22531 """
22532 rows_by_step = {}
22533 step_order = []
22534 active_step = None
22535 if not os.path.isfile(filepath):
22536 return rows_by_step, step_order
22537
22538 with open(filepath, "r", encoding="utf-8", errors="replace", newline="") as f:
22539 reader = csv.DictReader(f)
22540 for row in reader:
22541 step = _parse_int_loose(row.get("step"))
22542 if step is None:
22543 continue
22544 if step != active_step:
22545 active_step = step
22546 step_order.append(step)
22547 rows_by_step[step] = []
22548 rows_by_step.setdefault(step, []).append(
22549 {
22550 "function": row.get("function"),
22551 "calls": _parse_int_loose(row.get("calls")),
22552 "step_time_s": _parse_float_loose(row.get("step_time_s")),
22553 }
22554 )
22555 return rows_by_step, step_order
22556
22557
22558def _parse_runtime_memory_log(filepath: str) -> "tuple[dict, list[int], dict]":
22559 """!
22560 @brief Read the runtime memory record into rows by step and its final status.
22561 @param[in] filepath Runtime memory log path.
22562 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
22563 """
22564 rows_by_step, step_order = {}, []
22565 final_row = latest_sample_row = max_process_change_mb = None
22566 step_walls = []
22567 if not os.path.isfile(filepath):
22568 return rows_by_step, step_order, {"available": False}
22569 for _, row in _runtime_memory_rows(filepath):
22570 if row.get("step_wall_s") is not None:
22571 step_walls.append(row["step_wall_s"])
22572 change = row.get("process_change_mb_max")
22573 if change is not None:
22574 max_process_change_mb = change if max_process_change_mb is None else max(max_process_change_mb, change)
22575 if row["event"] in {"Step", "Post"}:
22576 rows_by_step[row["step"]] = row
22577 step_order.append(row["step"])
22578 latest_sample_row = row
22579 elif row["event"] in {"Shutdown", "Final"}:
22580 final_row = row
22581 meta = {
22582 "available": bool(rows_by_step or final_row),
22583 "source": filepath,
22584 "final_event": final_row.get("event") if final_row else None,
22585 "final_reason": final_row.get("reason") if final_row else None,
22586 "max_process_change_mb": max_process_change_mb,
22587 "mean_step_wall_s": (sum(step_walls) / len(step_walls)) if step_walls else None,
22588 "latest_sample_row": latest_sample_row,
22589 "final_row": final_row,
22590 }
22591 return rows_by_step, step_order, meta
22592
22593
22594def _parse_solution_convergence_log(log_dir: str) -> "tuple[dict, list[int]]":
22595 """!
22596 @brief Read the solution-convergence record into rows by step plus observed order.
22597 @param[in] log_dir Run log directory.
22598 @return Mapping step -> row of every column, and the steps in file order.
22599 """
22600 rows_by_step, step_order = {}, []
22601 for _, row in _solution_convergence_rows(log_dir)[1]:
22602 rows_by_step[row["step"]] = row
22603 step_order.append(row["step"])
22604 return rows_by_step, step_order
22605
22606
22607def _find_solver_stream_log_candidates(run_dir: str, log_dir: str) -> "list[str]":
22608 """!
22609 @brief Return plausible solver stream logs for local and Slurm runs.
22610 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
22611 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
22612 @return Value returned by `_find_solver_stream_log_candidates()`.
22613 """
22614 patterns = [
22615 os.path.join(run_dir, "scheduler", "*_solver.log"),
22616 os.path.join(run_dir, "scheduler", "solver_*.out"),
22617 os.path.join(log_dir, "*_solver.log"),
22618 ]
22619 found = []
22620 seen = set()
22621 for pattern in patterns:
22622 for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True):
22623 if path not in seen:
22624 seen.add(path)
22625 found.append(path)
22626 return found
22627
22628
22629def _parse_particle_snapshot_file(filepath: str) -> dict:
22630 """!
22631 @brief Parse sampled particle snapshots from a solver stream log.
22632 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
22633 @return Value returned by `_parse_particle_snapshot_file()`.
22634 """
22635 snapshots = {}
22636 if not os.path.isfile(filepath):
22637 return snapshots
22638
22639 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
22640 lines = f.readlines()
22641
22642 idx = 0
22643 while idx < len(lines):
22644 match = re.search(r"Particle states at step\s+(\d+):", lines[idx])
22645 if not match:
22646 idx += 1
22647 continue
22648 step = int(match.group(1))
22649 idx += 1
22650 rows = []
22651 while idx < len(lines):
22652 stripped = lines[idx].strip()
22653 if re.search(r"Particle states at step\s+\d+:", lines[idx]):
22654 break
22655 if stripped.startswith("|"):
22656 parts = [part.strip() for part in stripped.split("|")[1:-1]]
22657 if len(parts) >= 6 and parts[0] != "Rank":
22658 velocity = _extract_numeric_tuple(parts[4])
22659 rows.append(
22660 {
22661 "rank": _parse_int_loose(parts[0]),
22662 "pid": _parse_int_loose(parts[1]),
22663 "cell": [int(value) for value in _extract_numeric_tuple(parts[2])],
22664 "position": _extract_numeric_tuple(parts[3]),
22665 "velocity": velocity,
22666 "weights": _extract_numeric_tuple(parts[5]),
22667 "sample_speed": math.sqrt(sum(component * component for component in velocity)) if len(velocity) == 3 else None,
22668 }
22669 )
22670 elif rows and (not stripped or stripped.startswith("Progress:")):
22671 break
22672 idx += 1
22673 if rows:
22674 snapshots[step] = rows
22675 else:
22676 snapshots.setdefault(step, [])
22677 return snapshots
22678
22679
22680def _find_previous_snapshot_step(snapshot_steps: "list[int]", step: int) -> "int | None":
22681 """!
22682 @brief Return the nearest earlier snapshot step when available.
22683 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
22684 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
22685 @return Value returned by `_find_previous_snapshot_step()`.
22686 """
22687 earlier_steps = [candidate for candidate in snapshot_steps if candidate < step]
22688 if not earlier_steps:
22689 return None
22690 return max(earlier_steps)
22691
22692
22693def _compute_particle_snapshot_delta(current_rows: "list[dict]", previous_rows: "list[dict]") -> dict:
22694 """!
22695 @brief Compute sampled deltas between two particle snapshot samples.
22696 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
22697 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
22698 @return Value returned by `_compute_particle_snapshot_delta()`.
22699 """
22700 np = require_numpy()
22701 previous_by_pid = {
22702 row.get("pid"): row
22703 for row in previous_rows
22704 if row.get("pid") is not None
22705 }
22706 current_by_pid = {
22707 row.get("pid"): row
22708 for row in current_rows
22709 if row.get("pid") is not None
22710 }
22711 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
22712 if not matched_pids:
22713 return {"available": False}
22714
22715 displacements = []
22716 rank_migrations = 0
22717 cell_changes = 0
22718 speed_changes = []
22719 for pid in matched_pids:
22720 current_row = current_by_pid[pid]
22721 previous_row = previous_by_pid[pid]
22722 current_pos = current_row.get("position") or []
22723 previous_pos = previous_row.get("position") or []
22724 if len(current_pos) == len(previous_pos) and current_pos:
22725 displacements.append(
22726 math.sqrt(
22727 sum(
22728 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
22729 for idx in range(len(current_pos))
22730 )
22731 )
22732 )
22733 current_speed = current_row.get("sample_speed")
22734 previous_speed = previous_row.get("sample_speed")
22735 if current_speed is not None and previous_speed is not None:
22736 speed_changes.append(float(current_speed) - float(previous_speed))
22737 if current_row.get("rank") is not None and previous_row.get("rank") is not None:
22738 if current_row["rank"] != previous_row["rank"]:
22739 rank_migrations += 1
22740 if current_row.get("cell") and previous_row.get("cell"):
22741 if current_row["cell"] != previous_row["cell"]:
22742 cell_changes += 1
22743
22744 payload = {
22745 "available": True,
22746 "matched_pids": len(matched_pids),
22747 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
22748 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
22749 "rank_migrations": rank_migrations,
22750 "cell_changes": cell_changes,
22751 }
22752 if displacements:
22753 payload["mean_displacement"] = float(np.mean(displacements))
22754 payload["max_displacement"] = float(np.max(displacements))
22755 if speed_changes:
22756 payload["mean_speed_change"] = float(np.mean(speed_changes))
22757 payload["max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
22758 return payload
22759
22760
22762 source: str,
22763 step: int,
22764 rows: "list[dict]",
22765 preview_rows: int,
22766 particle_console_output_freq,
22767 particle_log_interval,
22768 previous_step: "int | None" = None,
22769 previous_rows: "list[dict] | None" = None,
22770) -> dict:
22771 """!
22772 @brief Build sampled diagnostics for one particle console snapshot.
22773 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
22774 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
22775 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
22776 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
22777 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
22778 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
22779 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
22780 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
22781 @return Value returned by `_build_particle_snapshot_summary()`.
22782 """
22783 np = require_numpy()
22784 payload = {
22785 "available": True,
22786 "sampled": True,
22787 "source": source,
22788 "step": step,
22789 "sampled_rows": len(rows),
22790 "preview_rows": rows[:preview_rows],
22791 "cadence": {
22792 "particle_console_output_frequency": particle_console_output_freq,
22793 "particle_log_interval": particle_log_interval,
22794 },
22795 }
22796 if not rows:
22797 return payload
22798
22799 rank_counts = {}
22800 duplicate_pid_count = 0
22801 duplicate_cell_count = 0
22802 nan_count = 0
22803 inf_count = 0
22804 zero_weight_count = 0
22805 negative_weight_count = 0
22806 unique_pid_count = 0
22807
22808 seen_pids = set()
22809 cell_counter = {}
22810 position_components = [[], [], []]
22811 weight_components = {}
22812 speeds = []
22813
22814 for row in rows:
22815 pid = row.get("pid")
22816 if pid is not None:
22817 if pid in seen_pids:
22818 duplicate_pid_count += 1
22819 else:
22820 seen_pids.add(pid)
22821 rank = row.get("rank")
22822 if rank is not None:
22823 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
22824
22825 cell = row.get("cell") or []
22826 if cell:
22827 key = tuple(cell)
22828 cell_counter[key] = cell_counter.get(key, 0) + 1
22829
22830 position = row.get("position") or []
22831 for idx, value in enumerate(position[:3]):
22832 if not np.isfinite(value):
22833 if np.isnan(value):
22834 nan_count += 1
22835 else:
22836 inf_count += 1
22837 continue
22838 position_components[idx].append(float(value))
22839
22840 velocity = row.get("velocity") or []
22841 if any(not np.isfinite(value) for value in velocity):
22842 for value in velocity:
22843 if not np.isfinite(value):
22844 if np.isnan(value):
22845 nan_count += 1
22846 else:
22847 inf_count += 1
22848 speed = row.get("sample_speed")
22849 if speed is not None and np.isfinite(speed):
22850 speeds.append(float(speed))
22851
22852 weights = row.get("weights") or []
22853 for idx, value in enumerate(weights):
22854 if not np.isfinite(value):
22855 if np.isnan(value):
22856 nan_count += 1
22857 else:
22858 inf_count += 1
22859 continue
22860 numeric = float(value)
22861 weight_components.setdefault(idx, []).append(numeric)
22862 if abs(numeric) <= 1.0e-15:
22863 zero_weight_count += 1
22864 if numeric < 0.0:
22865 negative_weight_count += 1
22866
22867 unique_pid_count = len(seen_pids)
22868 duplicate_cell_count = sum(1 for count in cell_counter.values() if count > 1)
22869
22870 payload["sampled_distribution"] = {
22871 "unique_cells": len(cell_counter),
22872 "duplicate_cells": duplicate_cell_count,
22873 "rank_counts": rank_counts,
22874 "unique_pids": unique_pid_count,
22875 }
22876 payload["checks"] = {
22877 "duplicate_pid_count": duplicate_pid_count,
22878 "nan_count": nan_count,
22879 "inf_count": inf_count,
22880 "zero_weight_count": zero_weight_count,
22881 "negative_weight_count": negative_weight_count,
22882 }
22883
22884 if speeds:
22885 payload["speed"] = {
22886 "min": float(np.min(speeds)),
22887 "mean": float(np.mean(speeds)),
22888 "max": float(np.max(speeds)),
22889 "std": float(np.std(speeds)),
22890 "stagnant_count": sum(1 for speed in speeds if abs(speed) < 1.0e-6),
22891 }
22892
22893 fastest_rows = sorted(
22894 [row for row in rows if row.get("sample_speed") is not None],
22895 key=lambda row: row["sample_speed"],
22896 reverse=True,
22897 )[:3]
22898 payload["top_speeds"] = [
22899 {
22900 "pid": row.get("pid"),
22901 "rank": row.get("rank"),
22902 "speed": row.get("sample_speed"),
22903 "cell": row.get("cell"),
22904 }
22905 for row in fastest_rows
22906 ]
22907
22908 if any(position_components):
22909 axes = ["x", "y", "z"]
22910 payload["position_bounds"] = {}
22911 centroid = []
22912 for idx, axis in enumerate(axes):
22913 values = position_components[idx]
22914 if values:
22915 payload["position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
22916 centroid.append(float(np.mean(values)))
22917 else:
22918 centroid.append(None)
22919 payload["position_centroid"] = centroid
22920
22921 if weight_components:
22922 payload["weights"] = {}
22923 for idx, values in sorted(weight_components.items()):
22924 payload["weights"][f"component_{idx}"] = {
22925 "min": float(np.min(values)),
22926 "max": float(np.max(values)),
22927 }
22928
22929 delta_summary = {"available": False}
22930 if previous_step is not None and previous_rows:
22931 delta_summary = _compute_particle_snapshot_delta(rows, previous_rows)
22932 if delta_summary.get("available"):
22933 delta_summary["previous_step"] = previous_step
22934 payload["delta_from_previous_snapshot"] = delta_summary
22935 return payload
22936
22937
22939 run_dir: str,
22940 log_dir: str,
22941 step: int,
22942 preview_rows: int,
22943 particle_console_output_freq,
22944 particle_log_interval,
22945) -> dict:
22946 """!
22947 @brief Locate and summarize a particle console snapshot for one step.
22948 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
22949 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
22950 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
22951 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
22952 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
22953 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
22954 @return Value returned by `_find_particle_snapshot_for_step()`.
22955 """
22956 best = None
22957 for path in _find_solver_stream_log_candidates(run_dir, log_dir):
22958 snapshots = _parse_particle_snapshot_file(path)
22959 rows = snapshots.get(step)
22960 if not rows:
22961 continue
22962 if best is None or len(rows) > len(best["rows"]):
22963 best = {"source": path, "rows": rows, "snapshots": snapshots}
22964
22965 if not best:
22966 return {"available": False}
22967
22968 previous_step = _find_previous_snapshot_step(list(best["snapshots"].keys()), step)
22969 previous_rows = best["snapshots"].get(previous_step, []) if previous_step is not None else None
22971 best["source"],
22972 step,
22973 best["rows"],
22974 preview_rows,
22975 particle_console_output_freq=particle_console_output_freq,
22976 particle_log_interval=particle_log_interval,
22977 previous_step=previous_step,
22978 previous_rows=previous_rows,
22979 )
22980
22981
22983 requested_step,
22984 continuity_rows,
22985 particle_rows,
22986 momentum_rows,
22987 poisson_rows,
22988 profiling_rows,
22989 memory_rows=None,
22990 convergence_rows=None,
22991 step_orders=None,
22992 selection_mode: str = "latest",
22993):
22994 """!
22995 @brief Select a step to summarize from available metric artifacts.
22996 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
22997 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
22998 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
22999 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
23000 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
23001 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
23002 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
23003 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
23004 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
23005 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
23006 @return Value returned by `_resolve_summary_step()`.
23007 """
23008 if memory_rows is None:
23009 memory_rows = {}
23010 if convergence_rows is None:
23011 convergence_rows = {}
23012 if step_orders is None:
23013 step_orders = []
23014 available_steps = (
23015 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
23016 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
23017 )
23018 if not available_steps:
23019 return None, []
23020
23021 if requested_step is not None:
23022 return requested_step, sorted(available_steps)
23023
23024 if selection_mode == "max_step":
23025 return max(available_steps), sorted(available_steps)
23026
23027 for order in step_orders:
23028 if order:
23029 return order[-1], sorted(available_steps)
23030 return max(available_steps), sorted(available_steps)
23031
23032
23033def _format_summary_float(value, spec: str = ".6e", missing: str = "n/a") -> str:
23034 """!
23035 @brief Format optional numeric values for summary text output.
23036 @param[in] value Argument passed to `_format_summary_float()`.
23037 @param[in] spec Argument passed to `_format_summary_float()`.
23038 @param[in] missing Argument passed to `_format_summary_float()`.
23039 @return Value returned by `_format_summary_float()`.
23040 """
23041 if value is None:
23042 return missing
23043 return format(value, spec)
23044
23045
23046def _summary_source_mtime(paths) -> float:
23047 """!
23048 @brief Return the newest modification time among one or more summary sources.
23049 @param[in] paths Path string, iterable of paths, or mapping of paths.
23050 @return Newest modification time, or -1.0 when no source exists.
23051 """
23052 if isinstance(paths, dict):
23053 paths = paths.values()
23054 elif isinstance(paths, str):
23055 paths = [paths]
23056 newest = -1.0
23057 for path in paths or []:
23058 if path and os.path.isfile(path):
23059 newest = max(newest, os.path.getmtime(path))
23060 return newest
23061
23062
23063def _order_summary_step_orders(sources: "list[tuple[list[int], object]]") -> "list[list[int]]":
23064 """!
23065 @brief Order observed step sequences by the recency of their source files.
23066 @param[in] sources Pairs of observed steps and filesystem source path(s).
23067 @return Step-order lists sorted so active append sources are considered first.
23068 """
23069 ranked = []
23070 for priority, (order, paths) in enumerate(sources):
23071 if order:
23072 ranked.append((_summary_source_mtime(paths), priority, order))
23073 ranked.sort(key=lambda item: (-item[0], item[1]))
23074 return [order for _, _, order in ranked]
23075
23076
23077def build_run_summary_payload(run_dir: str, step: "int | None" = None, snapshot_rows: int = 5, selection_mode: str = "latest") -> dict:
23078 """!
23079 @brief Build a read-only run-step summary from existing PICurv artifacts.
23080 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
23081 @param[in] step Argument passed to `build_run_summary_payload()`.
23082 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
23083 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
23084 @return Value returned by `build_run_summary_payload()`.
23085 """
23086 context = _build_summary_context(run_dir)
23087 log_dir = context["log_dir"]
23088 metrics_dir = context["metrics_dir"]
23089 continuity_path = (_log_family_path(log_dir, "Continuity_Metrics")
23090 or os.path.join(log_dir, "Continuity_Metrics.csv"))
23091 particle_metrics_path = (_particle_step_rows(log_dir, metrics_dir)[0]
23092 or os.path.join(metrics_dir, "search_metrics.csv"))
23093
23094 continuity_rows, continuity_order = _parse_continuity_metrics_log(log_dir)
23095 particle_rows, particle_order = _parse_particle_metrics_log(log_dir, metrics_dir)
23096 momentum_rows, momentum_sources, momentum_order = _parse_momentum_convergence_logs(log_dir)
23097 poisson_rows, poisson_sources, poisson_order = _parse_poisson_convergence_logs(log_dir)
23098
23099 profiling_rows = {}
23100 profiling_order = []
23101 profiling_path = os.path.join(log_dir, PROFILING_TIMESTEP_FILE)
23102 if context["profiling_cfg"].get("mode") != "off":
23103 profiling_rows, profiling_order = _parse_profiling_timestep_csv(profiling_path)
23104
23105 memory_path = _log_family_path(log_dir, "Runtime_Memory") or os.path.join(log_dir, RUNTIME_MEMORY_FILE)
23106 memory_rows, memory_order, memory_meta = _parse_runtime_memory_log(memory_path)
23107
23108 convergence_log_path = (_log_family_path(log_dir, "solution_convergence")
23109 or os.path.join(log_dir, "solution_convergence.csv"))
23110 convergence_rows, convergence_order = _parse_solution_convergence_log(log_dir)
23111 step_orders = _order_summary_step_orders(
23112 [
23113 (continuity_order, continuity_path),
23114 (particle_order, particle_metrics_path),
23115 (convergence_order, convergence_log_path),
23116 (profiling_order, profiling_path),
23117 (memory_order, memory_path),
23118 (momentum_order, momentum_sources),
23119 (poisson_order, poisson_sources),
23120 ]
23121 )
23122
23123 resolved_step, available_steps = _resolve_summary_step(
23124 step,
23125 continuity_rows,
23126 particle_rows,
23127 momentum_rows,
23128 poisson_rows,
23129 profiling_rows,
23130 convergence_rows,
23131 memory_rows,
23132 step_orders=step_orders,
23133 selection_mode=selection_mode,
23134 )
23135 if resolved_step is None:
23137 ERROR_CODE_CFG_FILE_NOT_FOUND,
23138 key="summary",
23139 file_path=log_dir,
23140 message="No summary-capable run artifacts were found under the run log directory.",
23141 hint="Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
23142 )
23143 sys.exit(1)
23144
23145 if step is not None and step not in set(available_steps):
23147 ERROR_CODE_CFG_INVALID_VALUE,
23148 key="step",
23149 file_path=context["run_dir"],
23150 message=f"Requested step {step} is not present in the available summary artifacts.",
23151 hint=f"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
23152 )
23153 sys.exit(1)
23154
23155 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=lambda row: row["block"])
23156 continuity_summary = {"available": bool(continuity_step_rows), "blocks": continuity_step_rows}
23157 if continuity_step_rows:
23158 divergence_values = [
23159 abs(row["max_divergence"])
23160 for row in continuity_step_rows
23161 if row["max_divergence"] is not None
23162 ]
23163 continuity_summary["max_abs_divergence"] = max(divergence_values) if divergence_values else None
23164 continuity_summary["net_flux"] = continuity_step_rows[0].get("net_flux")
23165 continuity_summary["flux_in"] = continuity_step_rows[0].get("flux_in")
23166 continuity_summary["flux_out"] = continuity_step_rows[0].get("flux_out")
23167
23168 momentum_step_rows = [row for _, row in sorted(momentum_rows.get(resolved_step, {}).items())]
23169 momentum_summary = {"available": bool(momentum_step_rows), "blocks": momentum_step_rows}
23170
23171 poisson_step_rows = [row for _, row in sorted(poisson_rows.get(resolved_step, {}).items())]
23172 poisson_summary = {"available": bool(poisson_step_rows), "blocks": poisson_step_rows}
23173
23174 particle_summary = {"available": resolved_step in particle_rows}
23175 if resolved_step in particle_rows:
23176 particle_summary.update(particle_rows[resolved_step])
23177
23178 profiling_summary = {"available": resolved_step in profiling_rows}
23179 if resolved_step in profiling_rows:
23180 functions = sorted(
23181 profiling_rows[resolved_step],
23182 key=lambda row: (row.get("step_time_s") or 0.0),
23183 reverse=True,
23184 )
23185 profiling_summary["functions"] = functions
23186 profiling_summary["total_logged_step_time_s"] = sum(
23187 row.get("step_time_s") or 0.0 for row in functions
23188 )
23189
23190 memory_summary = {"available": resolved_step in memory_rows}
23191 if resolved_step in memory_rows:
23192 memory_summary.update(memory_rows[resolved_step])
23193 memory_summary["source"] = memory_path
23194 memory_summary["max_process_change_mb"] = memory_meta.get("max_process_change_mb")
23195 memory_summary["mean_step_wall_s"] = memory_meta.get("mean_step_wall_s")
23196 memory_summary["final_event"] = memory_meta.get("final_event")
23197 memory_summary["final_reason"] = memory_meta.get("final_reason")
23198 memory_summary["selected_step"] = resolved_step
23199 memory_summary["step_match"] = True
23200 elif memory_meta.get("available"):
23201 latest_sample_row = memory_meta.get("latest_sample_row")
23202 if latest_sample_row:
23203 memory_summary.update(latest_sample_row)
23204 memory_summary.update(memory_meta)
23205 memory_summary["selected_step"] = resolved_step
23206 memory_summary["step_match"] = False
23207
23208 snapshot_summary = {"available": False}
23209 if context["particle_console_output_freq"] and context["particle_console_output_freq"] > 0:
23210 snapshot_summary = _find_particle_snapshot_for_step(
23211 context["run_dir"],
23212 log_dir,
23213 resolved_step,
23214 preview_rows=max(1, snapshot_rows),
23215 particle_console_output_freq=context["particle_console_output_freq"],
23216 particle_log_interval=context["particle_log_interval"],
23217 )
23218
23219 monitor_info = {
23220 "profiling_timestep_mode": context["profiling_cfg"].get("mode"),
23221 "profiling_timestep_file": PROFILING_TIMESTEP_FILE,
23222 "particle_console_output_frequency": context["particle_console_output_freq"],
23223 "particle_log_interval": context["particle_log_interval"],
23224 }
23225
23226 return {
23227 "run_id": context["manifest"].get("run_id", os.path.basename(context["run_dir"])),
23228 "run_dir": context["run_dir"],
23229 "step": resolved_step,
23230 "selected_via": "explicit" if step is not None else ("max_step" if selection_mode == "max_step" else "latest_available"),
23231 "available_steps": available_steps,
23232 "launch_mode": context["manifest"].get("launch_mode"),
23233 "created_at": context["manifest"].get("created_at"),
23234 "monitor": monitor_info,
23235 "particles_configured": context["particle_count_cfg"],
23236 "sources": {
23237 "continuity_log": continuity_path if os.path.isfile(continuity_path) else None,
23238 "particle_metrics_log": particle_metrics_path if os.path.isfile(particle_metrics_path) else None,
23239 "momentum_logs": momentum_sources,
23240 "poisson_logs": poisson_sources,
23241 "profiling_timestep_csv": profiling_path if os.path.isfile(profiling_path) else None,
23242 "solution_convergence_log": convergence_log_path if os.path.isfile(convergence_log_path) else None,
23243 "runtime_memory_log": memory_path if os.path.isfile(memory_path) else None,
23244 },
23245 "continuity": continuity_summary,
23246 "momentum": momentum_summary,
23247 "poisson": poisson_summary,
23248 "particles": particle_summary,
23249 "particle_snapshot": snapshot_summary,
23250 "profiling": profiling_summary,
23251 "memory": memory_summary,
23252 "convergence": convergence_rows.get(resolved_step) if convergence_rows else None,
23253 }
23254
23255
23256def render_run_summary(payload: dict, output_format: str = "text"):
23257 """!
23258 @brief Render a run-step summary in human or JSON form.
23259 @param[in] payload Argument passed to `render_run_summary()`.
23260 @param[in] output_format Argument passed to `render_run_summary()`.
23261 """
23262 if output_format == "json":
23263 print(json.dumps(payload, indent=2, sort_keys=True))
23264 return
23265
23266 print("\n" + "=" * 60)
23267 print(" RUN STEP SUMMARY")
23268 print("=" * 60)
23269 print(f" Run ID : {payload.get('run_id')}")
23270 print(f" Run directory : {os.path.relpath(payload.get('run_dir'))}")
23271 print(f" Step : {payload.get('step')} ({payload.get('selected_via')})")
23272 if payload.get("launch_mode"):
23273 print(f" Launch mode : {payload.get('launch_mode')}")
23274 if payload.get("created_at"):
23275 print(f" Created at : {payload.get('created_at')}")
23276
23277 continuity = payload.get("continuity", {})
23278 print("\n Continuity:")
23279 if continuity.get("available"):
23280 if continuity.get("max_abs_divergence") is not None:
23281 print(f" max |div| : {continuity['max_abs_divergence']:.6e}")
23282 if continuity.get("net_flux") is not None:
23283 print(f" net flux : {continuity['net_flux']:.6e}")
23284 for row in continuity.get("blocks", []):
23285 print(
23286 " "
23287 f"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
23288 f"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
23289 )
23290 else:
23291 print(" unavailable")
23292
23293 momentum = payload.get("momentum", {})
23294 print("\n Momentum:")
23295 if momentum.get("available"):
23296 for row in momentum.get("blocks", []):
23297 if row.get("solver") == "Newton Krylov":
23298 print(f" block {row['block']}: solver=Newton Krylov")
23299 print(
23300 f" newton={row.get('newton_iterations')} "
23301 f"krylov={row.get('krylov_iterations')} "
23302 f"evals={row.get('residual_evaluations')}"
23303 )
23304 print(
23305 f" final={_format_summary_float(row.get('final_norm'))} "
23306 f"reason={row.get('reason')} state={row.get('state')}"
23307 )
23308 continue
23309 status = row.get("status") or "unknown"
23310 accepted = row.get("accepted_count")
23311 rejected = row.get("rejected_count")
23312 counts_str = f" accepted={accepted} rejected={rejected}" if accepted is not None else ""
23313 # Phase 3+: logged as dtau (physical time) + cfl_eff (dimensionless Courant number).
23314 dtau_val = row.get("dtau")
23315 cfl_in = row.get("cfl_eff")
23316 cfl_out = row.get("cfl_eff_after")
23317 dtau_out = row.get("dtau_after")
23318 if cfl_in is not None and cfl_out is not None:
23319 cfl_str = f"cfl_eff {cfl_in:.4f}->{cfl_out:.4f} dtau {_format_summary_float(dtau_val)}->{_format_summary_float(dtau_out)}"
23320 elif cfl_in is not None:
23321 cfl_str = f"cfl_eff={_format_summary_float(cfl_in, '.4f')} dtau={_format_summary_float(dtau_val)}"
23322 else:
23323 cfl_str = "cfl_eff=n/a"
23324 ratio = row.get("trial_ratio")
23325 smoothed = row.get("smoothed_ratio")
23326 if ratio is not None and smoothed is not None:
23327 ratio_str = f" ratio={_format_summary_float(ratio)} (ema={_format_summary_float(smoothed)})"
23328 elif ratio is not None:
23329 ratio_str = f" ratio={_format_summary_float(ratio)}"
23330 else:
23331 ratio_str = ""
23332 print(f" block {row['block']} [{status}]:{counts_str} {cfl_str}{ratio_str}")
23333 print(
23334 f" resid={_format_summary_float(row.get('residual_norm'))}"
23335 f" delta={_format_summary_float(row.get('delta_norm'))}"
23336 )
23337 else:
23338 print(" unavailable")
23339
23340 poisson = payload.get("poisson", {})
23341 print("\n Poisson:")
23342 if poisson.get("available"):
23343 for row in poisson.get("blocks", []):
23344 print(
23345 " "
23346 f"block {row['block']}: iter={row['iterations']} "
23347 f"true={_format_summary_float(row.get('true_norm'))} "
23348 f"rel={_format_summary_float(row.get('relative_norm'))}"
23349 )
23350 else:
23351 print(" unavailable")
23352
23353 convergence = payload.get("convergence")
23354 print("\n Solution Convergence:")
23355 if convergence is not None:
23356 mode = convergence.get("mode", "unknown")
23357 ref = convergence.get("ref")
23358 print(f" mode : {mode} (ref={'yes' if ref else 'no'})")
23359 if mode in ("steady_deterministic", "transient"):
23360 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
23361 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
23362 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
23363 elif mode == "periodic_deterministic":
23364 ph = convergence.get("ph")
23365 per = convergence.get("per")
23366 print(f" phase : {ph}/{per}")
23367 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
23368 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
23369 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
23370 elif mode == "statistical_steady":
23371 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} win={_format_summary_float(convergence.get('spd_win'))} win_drift={_format_summary_float(convergence.get('spd_win_abs'))}")
23372 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} win={_format_summary_float(convergence.get('ke_win'))} win_drift={_format_summary_float(convergence.get('ke_win_abs'))}")
23373 else:
23374 print(" unavailable")
23375
23376 particles = payload.get("particles", {})
23377 print("\n Particles:")
23378 if particles.get("available"):
23379 loss_summary = f"lost={particles.get('lost_particles')}"
23380 if particles.get("lost_particles_cumulative") is not None:
23381 loss_summary = (
23382 f"lost(step/total)={particles.get('lost_particles')}/"
23383 f"{particles.get('lost_particles_cumulative')}"
23384 )
23385 if particles.get("exited_particles_cumulative") is not None:
23386 loss_summary += (
23387 f" exited(step/total)={particles.get('exited_particles')}/"
23388 f"{particles.get('exited_particles_cumulative')}"
23389 )
23390 print(
23391 " "
23392 f"total={particles.get('total_particles')} {loss_summary} "
23393 f"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
23394 f"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
23395 )
23396 else:
23397 print(" unavailable")
23398
23399 memory = payload.get("memory", {})
23400 print("\n Runtime Memory:")
23401 if memory.get("available"):
23402 if memory.get("source"):
23403 print(f" source : {os.path.relpath(memory.get('source'))}")
23404 if memory.get("step") is not None and not memory.get("step_match", True):
23405 print(f" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
23406 if memory.get("event"):
23407 print(f" event : {memory.get('event')} reason={memory.get('reason', '-')}")
23408 print(f" process max : {_format_summary_float(memory.get('process_current_mb_max'), '.3f')} MB current, {_format_summary_float(memory.get('process_peak_mb_max'), '.3f')} MB peak")
23409 print(f" PETSc max : {_format_summary_float(memory.get('petsc_allocated_mb_max'), '.3f')} MB allocated, {_format_summary_float(memory.get('petsc_peak_allocated_mb_max'), '.3f')} MB peak")
23410 print(f" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
23411 if memory.get("step_wall_s") is not None or memory.get("mean_step_wall_s") is not None:
23412 print(f" step time : {_format_summary_float(memory.get('step_wall_s'), '.4g')} s this step, "
23413 f"{_format_summary_float(memory.get('mean_step_wall_s'), '.4g')} s mean (slowest rank)")
23414 if memory.get("final_reason"):
23415 print(f" final reason : {memory.get('final_reason')}")
23416 else:
23417 print(" final reason : none recorded (still running, or stopped without a final row)")
23418 else:
23419 print(" unavailable")
23420
23421 snapshot = payload.get("particle_snapshot", {})
23422 if snapshot.get("available"):
23423 print("\n Particle Snapshot (sampled):")
23424 print(f" source : {os.path.relpath(snapshot.get('source'))}")
23425 cadence = snapshot.get("cadence", {})
23426 print(
23427 " "
23428 f"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
23429 f"row interval {cadence.get('particle_log_interval', 'n/a')}"
23430 )
23431 print(f" sampled rows : {snapshot.get('sampled_rows')}")
23432 speed = snapshot.get("speed", {})
23433 if speed:
23434 print(
23435 " "
23436 f"sampled speeds: min={_format_summary_float(speed.get('min'))} "
23437 f"mean={_format_summary_float(speed.get('mean'))} "
23438 f"max={_format_summary_float(speed.get('max'))} "
23439 f"std={_format_summary_float(speed.get('std'))} "
23440 f"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
23441 )
23442 bounds = snapshot.get("position_bounds", {})
23443 centroid = snapshot.get("position_centroid")
23444 if bounds:
23445 bound_parts = []
23446 for axis in ("x", "y", "z"):
23447 if axis in bounds:
23448 bound_parts.append(
23449 f"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
23450 )
23451 print(f" sampled bounds: {' '.join(bound_parts)}")
23452 if centroid:
23453 print(
23454 " "
23455 f"sampled center: ({_format_summary_float(centroid[0])}, "
23456 f"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
23457 )
23458 distribution = snapshot.get("sampled_distribution", {})
23459 if distribution:
23460 print(
23461 " "
23462 f"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
23463 f"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
23464 f"unique_pids={distribution.get('unique_pids', 'n/a')} "
23465 f"ranks={distribution.get('rank_counts', {})}"
23466 )
23467 weights = snapshot.get("weights", {})
23468 if weights:
23469 weight_parts = []
23470 for component, summary in sorted(weights.items()):
23471 weight_parts.append(
23472 f"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
23473 )
23474 print(f" sampled weights: {' '.join(weight_parts)}")
23475 checks = snapshot.get("checks", {})
23476 if checks:
23477 print(
23478 " "
23479 f"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
23480 f"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
23481 f"zero_weight={checks.get('zero_weight_count', 0)} "
23482 f"negative_weight={checks.get('negative_weight_count', 0)}"
23483 )
23484 top_speeds = snapshot.get("top_speeds", [])
23485 if top_speeds:
23486 summary = ", ".join(
23487 f"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
23488 for row in top_speeds
23489 )
23490 print(f" top speeds : {summary}")
23491 delta_summary = snapshot.get("delta_from_previous_snapshot", {})
23492 if delta_summary.get("available"):
23493 print(
23494 " "
23495 f"vs prev snap : step={delta_summary.get('previous_step')} "
23496 f"matched_pids={delta_summary.get('matched_pids')} "
23497 f"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
23498 f"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
23499 f"rank_moves={delta_summary.get('rank_migrations')} "
23500 f"cell_changes={delta_summary.get('cell_changes')} "
23501 f"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
23502 )
23503 print(" preview rows :")
23504 for row in snapshot.get("preview_rows", []):
23505 print(
23506 " "
23507 f"pid={row.get('pid')} rank={row.get('rank')} "
23508 f"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
23509 )
23510
23511 profiling = payload.get("profiling", {})
23512 print("\n Profiling:")
23513 if profiling.get("available"):
23514 print(f" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
23515 for row in profiling.get("functions", [])[:5]:
23516 print(
23517 " "
23518 f"{row.get('function')}: calls={row.get('calls')} "
23519 f"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
23520 )
23521 else:
23522 print(" unavailable")
23523 print("=" * 60)
23524
23525
23526_CONFIG_SUMMARY_WIDTH = 78
23527
23528
23529def _summary_display_value(value) -> str:
23530 """!
23531 @brief Format one configuration-summary value for compact text output.
23532 @param[in] value Value to format.
23533 @return Compact human-readable value.
23534 """
23535 if value is None:
23536 return "-"
23537 if isinstance(value, bool):
23538 return "enabled" if value else "disabled"
23539 if isinstance(value, float):
23540 return f"{value:.6g}"
23541 if isinstance(value, (list, tuple)):
23542 return ", ".join(_summary_display_value(item) for item in value) if value else "none"
23543 if isinstance(value, dict):
23544 if not value:
23545 return "none"
23546 return ", ".join(f"{key}={_summary_display_value(item)}" for key, item in value.items())
23547 return str(value)
23548
23549
23550def _print_config_header(title: str, subtitle: "str | None" = None):
23551 """!
23552 @brief Print a strong dashboard-style configuration summary header.
23553 @param[in] title Section title.
23554 @param[in] subtitle Optional one-line section subtitle.
23555 """
23556 print("\n" + "=" * _CONFIG_SUMMARY_WIDTH)
23557 print(f"{title:^78}")
23558 if subtitle:
23559 print(f"{subtitle:^78}")
23560 print("=" * _CONFIG_SUMMARY_WIDTH)
23561
23562
23563def _print_config_group(title: str, rows: list):
23564 """!
23565 @brief Print an aligned configuration-summary field group.
23566 @param[in] title Group title.
23567 @param[in] rows Sequence of `(label, value)` pairs.
23568 """
23569 visible_rows = [(label, value) for label, value in rows if value is not None]
23570 if not visible_rows:
23571 return
23572 print(f"\n {title}")
23573 print(f" {'-' * (len(title) + 1)}")
23574 for label, value in visible_rows:
23575 print(f" {label:<32} {_summary_display_value(value)}")
23576
23577
23578def _flatten_summary_mapping(mapping: dict, prefix: str = "") -> list:
23579 """!
23580 @brief Flatten nested summary mappings into readable dotted field rows.
23581 @param[in] mapping Mapping to flatten.
23582 @param[in] prefix Optional parent-field prefix.
23583 @return Sequence of `(field, value)` pairs.
23584 """
23585 rows = []
23586 for key, value in mapping.items():
23587 label = f"{prefix}.{key}" if prefix else str(key)
23588 if isinstance(value, dict) and value:
23589 rows.extend(_flatten_summary_mapping(value, label))
23590 else:
23591 rows.append((label, value))
23592 return rows
23593
23594
23595def _render_run_overview_text(summary: dict):
23596 """!
23597 @brief Render run metadata as a compact dashboard.
23598 @param[in] summary Curated run overview mapping.
23599 """
23600 _print_config_header("RUN OVERVIEW", summary.get("run_id"))
23602 "Identity",
23603 [
23604 ("Run directory", os.path.relpath(summary.get("run_dir")) if summary.get("run_dir") else None),
23605 ("Created", summary.get("created_at")),
23606 ("Launch mode", summary.get("launch_mode")),
23607 ("PICurv release", summary.get("release_version")),
23608 ("Build", summary.get("build_id")),
23609 ("Git commit", summary.get("git_commit")),
23610 ],
23611 )
23613 "Execution",
23614 [
23615 ("Solver MPI processes", summary.get("solver_num_procs")),
23616 ("Post MPI processes", summary.get("post_num_procs")),
23617 ("Stages requested", summary.get("stages_requested")),
23618 ("Stages ready/completed", summary.get("stages_completed_or_submitted")),
23619 ],
23620 )
23621
23622
23623def _render_case_summary_text(summary: dict):
23624 """!
23625 @brief Render the case summary as a glanceable simulation dashboard.
23626 @param[in] summary Curated case configuration mapping.
23627 """
23628 run = summary.get("run_control", {})
23629 props = summary.get("properties", {})
23630 grid = summary.get("grid", {})
23631 domain = summary.get("domain", {})
23632 physics = summary.get("physics", {})
23633 subtitle = (
23634 f"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
23635 f"Re={_summary_display_value(props.get('reynolds_number'))}"
23636 )
23637 _print_config_header("CASE SUMMARY", subtitle)
23639 "Simulation",
23640 [
23641 ("Step range", f"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
23642 ("Physical timestep", run.get("dt_physical")),
23643 ("Nondimensional timestep", run.get("dt_nondimensional")),
23644 ("Physical duration", run.get("duration_physical")),
23645 ("Initial conditions", props.get("initial_conditions")),
23646 ],
23647 )
23649 "Fluid And Scaling",
23650 [
23651 ("Reynolds number", props.get("reynolds_number")),
23652 ("Reference length", props.get("length_ref")),
23653 ("Reference velocity", props.get("velocity_ref")),
23654 ("Density", props.get("density")),
23655 ("Viscosity", props.get("viscosity")),
23656 ],
23657 )
23659 "Domain And Grid",
23660 [
23661 ("Grid mode", grid.get("mode")),
23662 ("Blocks", domain.get("blocks")),
23663 ("Dimensionality", domain.get("dimensionality")),
23664 ("Periodic axes", domain.get("periodic")),
23665 ("MPI grid layout", grid.get("processor_layout")),
23666 ("Grid source", grid.get("source_file")),
23667 ],
23668 )
23669 if grid.get("programmatic_settings"):
23670 _print_config_group("Programmatic Grid", _flatten_summary_mapping(grid["programmatic_settings"]))
23672 "Physics",
23673 [
23674 ("Particles", physics.get("particles")),
23675 ("FSI", physics.get("fsi")),
23676 ("Turbulence", physics.get("turbulence")),
23677 ("Statistics", physics.get("statistics")),
23678 ],
23679 )
23680 boundary_blocks = summary.get("boundary_conditions", [])
23681 if boundary_blocks:
23682 print("\n Boundary Conditions")
23683 print(" --------------------")
23684 print(f" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
23685 print(f" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
23686 for block in boundary_blocks:
23687 for face in block.get("faces", []):
23688 print(
23689 f" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
23690 f"{face.get('type', '-'):<12} {face.get('handler', '-')}"
23691 )
23692
23693
23695 """!
23696 @brief Render the solver summary as a glanceable numerical-method dashboard.
23697 @param[in] summary Curated solver configuration mapping.
23698 """
23699 momentum = summary.get("momentum", {})
23700 poisson = summary.get("poisson", {})
23701 operation = summary.get("operation_mode", {})
23702 subtitle = (
23703 f"Field: {operation.get('eulerian_field_source', '-')} | "
23704 f"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
23705 )
23706 _print_config_header("SOLVER SUMMARY", subtitle)
23707 _print_config_group("Operation", _flatten_summary_mapping(operation))
23709 "Primary Methods",
23710 [
23711 ("Momentum solver", momentum.get("type")),
23712 ("Central differencing", momentum.get("central_diff")),
23713 ("Poisson method", poisson.get("method")),
23714 ("Interpolation", summary.get("interpolation")),
23715 ("Convergence mode", summary.get("solution_convergence", {}).get("mode")),
23716 ],
23717 )
23718 _print_config_group("Momentum Tolerances", _flatten_summary_mapping(momentum.get("tolerances", {})))
23719 control_heading = (
23720 "Newton--Krylov Controls" if momentum.get("type") == "newton_krylov"
23721 else "Dual-Time Pseudo-Time Controls" if momentum.get("type") == "DUALTIME_PICARD_JAMESON_RK"
23722 else "Momentum Controls"
23723 )
23724 _print_config_group(control_heading, _flatten_summary_mapping(momentum.get("controls", {})))
23725 _print_config_group("Poisson Configuration", _flatten_summary_mapping(poisson))
23726 _print_config_group("Solution Convergence", _flatten_summary_mapping(summary.get("solution_convergence", {})))
23727 _print_config_group("Scalar Transport", _flatten_summary_mapping(summary.get("scalar_transport", {})))
23728 _print_config_group("Verification Sources", _flatten_summary_mapping(summary.get("verification", {})))
23729 passthrough = summary.get("petsc_passthrough", {})
23731 "Advanced PETSc Options",
23732 [("Option count", passthrough.get("count")), ("Option names", passthrough.get("options"))],
23733 )
23734
23735
23737 """!
23738 @brief Render the monitor summary as a glanceable observability dashboard.
23739 @param[in] summary Curated monitor configuration mapping.
23740 """
23741 logging_cfg = summary.get("logging", {})
23742 profiling = summary.get("profiling", {})
23743 diagnostics = summary.get("diagnostics", {})
23744 io_cfg = summary.get("io", {})
23745 memory_log = diagnostics.get("runtime_memory_log", {})
23746 subtitle = (
23747 f"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
23748 f"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
23749 )
23750 _print_config_header("MONITOR SUMMARY", subtitle)
23752 "Logging",
23753 [
23754 ("Verbosity", logging_cfg.get("verbosity")),
23755 ("Enabled functions", logging_cfg.get("enabled_functions")),
23756 ],
23757 )
23758 _print_config_group("Profiling", _flatten_summary_mapping(profiling))
23760 "Output Cadence",
23761 [
23762 ("Field output", io_cfg.get("data_output_frequency")),
23763 ("Particle snapshots", io_cfg.get("particle_console_output_frequency")),
23764 ("Particle row interval", io_cfg.get("particle_log_interval")),
23765 ],
23766 )
23767 _print_config_group("Output Directories", _flatten_summary_mapping(io_cfg.get("directories", {})))
23769 "Diagnostics",
23770 [
23771 ("Enabled PETSc diagnostics", diagnostics.get("enabled_petsc")),
23772 ("Runtime memory log", memory_log.get("enabled")),
23773 ],
23774 )
23775 _print_config_group("PETSc Diagnostic Settings", _flatten_summary_mapping(diagnostics.get("petsc", {})))
23776 solver_monitoring = summary.get("solver_monitoring", {})
23778 "Solver Monitoring",
23779 [
23780 ("Enabled flags", solver_monitoring.get("enabled_flags")),
23781 ("All flags", solver_monitoring.get("flags")),
23782 ],
23783 )
23784
23785
23786def render_selected_summary(payload: dict, output_format: str = "text"):
23787 """!
23788 @brief Render selected timestep-independent config views and optional health.
23789 @param[in] payload Combined selected summary payload.
23790 @param[in] output_format Output format.
23791 """
23792 if output_format == "json":
23793 json_payload = {key: value for key, value in payload.items() if key != "_health_requested"}
23794 print(json.dumps(json_payload, indent=2, sort_keys=True))
23795 return
23796
23797 if payload.get("run_overview") is not None:
23798 _render_run_overview_text(payload["run_overview"])
23799 renderers = {
23800 "case": _render_case_summary_text,
23801 "solver": _render_solver_summary_text,
23802 "monitor": _render_monitor_summary_text,
23803 }
23804 for key in ("case", "solver", "monitor"):
23805 if key in payload.get("configuration", {}):
23806 renderers[key](payload["configuration"][key])
23807 storage = payload.get("storage")
23808 if isinstance(storage, dict):
23809 print("\nSTORAGE")
23810 print("=" * 78)
23811 print(f" State : {storage.get('state', 'LOCAL')}")
23812 if storage.get("archive_id"):
23813 print(f" Archive ID : {storage['archive_id']}")
23814 if storage.get("label"):
23815 print(f" Label : {storage['label']}")
23816 if payload.get("_health_requested"):
23817 health_payload = {
23818 key: value for key, value in payload.items()
23819 if key not in {"run_overview", "configuration", "storage", "_health_requested"}
23820 }
23821 render_run_summary(health_payload, output_format="text")
23822
23823
23824_SUMMARY_PLOT_LOG_SCALE_FIELDS = {
23825 "max_divergence", "delta_norm", "delta_rel", "residual_norm", "residual_rel",
23826 "unpreconditioned_norm", "true_norm", "relative_norm",
23827 "u_abs_l2", "u_rel_l2", "p_abs_l2", "p_rel_l2",
23828 "spd_abs", "spd_rel", "ke_abs", "ke_rel",
23829 "spd_win_abs", "spd_win_rel", "spd_rms_abs", "spd_rms_rel",
23830 "ke_win_abs", "ke_win_rel", "ke_rms_abs", "ke_rms_rel",
23831 "parseval_residual", "zero_mode_energy",
23832}
23833
23834
23835_SUMMARY_PLOT_FIELD_LABELS = {
23836 "max_divergence": "Maximum |divergence|",
23837 "rhs_sum": "Poisson source imbalance",
23838 "flux_in": "Inflow flux",
23839 "flux_out": "Outflow flux",
23840 "net_flux": "Net boundary flux",
23841 "total_particles": "Particle count",
23842 "lost_particles": "Particles lost per step (search failures)",
23843 "lost_particles_cumulative": "Cumulative particles lost (search failures)",
23844 "exited_particles": "Particles that left the domain per step",
23845 "exited_particles_cumulative": "Cumulative particles that left the domain",
23846 "migrated_particles": "Migrated particles",
23847 "occupied_cells": "Occupied cells",
23848 "load_imbalance": "Particle load imbalance",
23849 "migration_passes": "Particle migration passes",
23850 "pseudo_iterations": "Pseudo-iterations to convergence",
23851 "newton_iterations": "Newton iterations to convergence",
23852 "iterations": "Linear iterations to convergence",
23853 "dtau": "Pseudo-time step, Δτ",
23854 "dtau_after": "Accepted pseudo-time step, Δτ",
23855 "cfl_eff": "Effective pseudo-CFL",
23856 "cfl_eff_after": "Accepted effective pseudo-CFL",
23857 "delta_norm": "Momentum update norm, ‖ΔU‖",
23858 "delta_rel": "Relative momentum update, ‖ΔU‖/‖ΔU₀‖",
23859 "residual_norm": "Residual norm",
23860 "residual_rel": "Relative residual norm",
23861 "trial_ratio": "Pseudo-CFL trial ratio",
23862 "smoothed_ratio": "Smoothed pseudo-CFL ratio",
23863 "unpreconditioned_norm": "Unpreconditioned residual norm",
23864 "true_norm": "True residual norm",
23865 "relative_norm": "Relative residual norm",
23866 "calls": "Calls per physical step",
23867 "step_time_s": "Wall time per physical step (s)",
23868 "process_current_mb_max": "Maximum resident memory (MiB)",
23869 "process_peak_mb_max": "Peak resident memory (MiB)",
23870 "petsc_allocated_mb_max": "PETSc allocated memory (MiB)",
23871 "petsc_peak_allocated_mb_max": "Peak PETSc allocated memory (MiB)",
23872 "process_change_mb_max": "Resident-memory change (MiB)",
23873 "u_abs_l2": "Velocity L² error",
23874 "u_rel_l2": "Relative velocity L² error",
23875 "p_abs_l2": "Pressure L² error",
23876 "p_rel_l2": "Relative pressure L² error",
23877 "mean_speed": "Volume-mean speed",
23878 "spd_ref": "Reference mean speed",
23879 "spd_abs": "Absolute mean-speed drift",
23880 "spd_rel": "Relative mean-speed drift",
23881 "mean_ke": "Mean kinetic energy",
23882 "ke_ref": "Reference mean kinetic energy",
23883 "ke_abs": "Absolute kinetic-energy drift",
23884 "ke_rel": "Relative kinetic-energy drift",
23885 "spd_win": "Window-mean speed",
23886 "spd_win_prev": "Previous window-mean speed",
23887 "spd_win_abs": "Absolute window-mean speed drift",
23888 "spd_win_rel": "Relative window-mean speed drift",
23889 "spd_rms_win": "Window RMS mean speed",
23890 "spd_rms_abs": "Absolute RMS speed drift",
23891 "spd_rms_rel": "Relative RMS speed drift",
23892 "ke_win": "Window-mean kinetic energy",
23893 "ke_win_prev": "Previous window-mean kinetic energy",
23894 "ke_win_abs": "Absolute window-mean kinetic-energy drift",
23895 "ke_win_rel": "Relative window-mean kinetic-energy drift",
23896 "ke_rms_win": "Window RMS kinetic energy",
23897 "ke_rms_abs": "Absolute RMS kinetic-energy drift",
23898 "ke_rms_rel": "Relative RMS kinetic-energy drift",
23899 "resolved_kinetic_energy": "Resolved kinetic energy",
23900 "spectrum_total_energy": "Integrated spectral energy",
23901 "parseval_residual": "Parseval residual",
23902 "spectrum_peak_k": "Peak wavenumber, kₚₑₐₖ",
23903 "zero_mode_energy": "Zero-mode energy",
23904 "integral_length_scale": "Integral length scale",
23905 "taylor_microscale": "Taylor microscale",
23906 "dissipation_over_viscosity": "Dissipation / kinematic viscosity",
23907}
23908
23909
23910_SUMMARY_PLOT_SOURCE_TITLES = {
23911 "continuity": "Continuity",
23912 "particles": "Particle transport",
23913 "momentum": "Momentum solver",
23914 "poisson": "Pressure Poisson solver",
23915 "profiling": "Runtime profile",
23916 "memory": "Runtime memory",
23917 "convergence": "Solution convergence",
23918 "spectra": "Turbulence spectrum",
23919}
23920
23921
23922_SUMMARY_ITERATION_HISTORY_FIELDS = {
23923 "dtau", "dtau_after", "cfl_eff", "cfl_eff_after", "delta_norm", "delta_rel",
23924 "residual_norm", "residual_rel", "trial_ratio", "smoothed_ratio",
23925 "unpreconditioned_norm", "true_norm", "relative_norm",
23926}
23927
23928
23929_SUMMARY_COUNT_FIELDS = {
23930 "total_particles", "lost_particles", "lost_particles_cumulative",
23931 "exited_particles", "exited_particles_cumulative", "migrated_particles", "occupied_cells", "migration_passes", "calls",
23932 "pseudo_iterations", "newton_iterations", "iterations",
23933}
23934
23935
23936def _humanize_plot_identifier(value: str) -> str:
23937 """!
23938 @brief Convert one machine-oriented identifier into a readable plot label.
23939 @param[in] value Dotted path or snake-case identifier.
23940 @return Human-readable label.
23941 """
23942 text = str(value).split(".")[-1].replace("_", " ").strip()
23943 replacements = {
23944 "l2": "L²", "linf": "L∞", "msd": "mean-squared displacement",
23945 "pct": "percentage", "p95": "95th percentile", "cfl": "CFL",
23946 "ke": "kinetic energy", "rms": "RMS",
23947 }
23948 words = [replacements.get(word.lower(), word) for word in text.split()]
23949 return " ".join(words[:1]).capitalize() + (" " + " ".join(words[1:]) if len(words) > 1 else "")
23950
23951
23952def _summary_field_label(field: str) -> str:
23953 """!
23954 @brief Return the report-facing label for one logged scalar field.
23955 @param[in] field Logged scalar field name.
23956 @return Report-facing label.
23957 """
23958 return _SUMMARY_PLOT_FIELD_LABELS.get(field, _humanize_plot_identifier(field))
23959
23960
23961def _summary_physical_time(context: dict, record: dict) -> "float | None":
23962 """!
23963 @brief Resolve a record's physical time from its artifact or copied case configuration.
23964 @param[in] context Summary context with copied case configuration.
23965 @param[in] record Collected plot record.
23966 @return Physical time, or None when it cannot be resolved.
23967 """
23968 explicit = record.get("coordinates", {}).get("time")
23969 if explicit is not None:
23970 return float(explicit)
23971 try:
23972 dt = float((context.get("case_cfg") or {}).get("run_control", {}).get("dt_physical"))
23973 except (TypeError, ValueError):
23974 return None
23975 return float(record["step"]) * dt if math.isfinite(dt) and dt > 0.0 else None
23976
23977
23979 """!
23980 @brief Yields `(segment, row)` for each data line of one of the solver's CSV records.
23981
23982 Every per-step file the solver writes shares one shape: a header (the first line that is
23983 not a comment) written once, data rows appended across the run, `#` comment lines, and a
23984 `# Continuation from step N` marker at each seam where a continuation resumed. A row with
23985 fewer fields than the header is skipped rather than guessed at; one with more keeps the
23986 header's columns, since columns are only ever appended. `segment` counts the
23987 continuations seen so far, so a caller can keep restarts as separate series instead of
23988 drawing a line across the seam.
23989
23990 @param path CSV to read.
23991 @return Generator of `(segment_index, {column: text})` pairs.
23992 """
23993 segment = 0
23994 header = []
23995 with open(path, "r", encoding="utf-8", errors="replace", newline="") as handle:
23996 for raw_line in handle:
23998 segment += 1
23999 continue
24000 stripped = raw_line.strip()
24001 if not stripped or stripped.startswith("#"):
24002 continue
24003 parts = [part.strip() for part in stripped.split(",")]
24004 if not header:
24005 header = parts
24006 continue
24007 if len(parts) < len(header):
24008 continue
24009 yield segment, dict(zip(header, parts))
24010
24011
24012def _log_family_path(log_dir: str, base: str) -> "str | None":
24013 """!
24014 @brief Return a solver record's CSV, or the text log an older run wrote instead.
24015 @param[in] log_dir Run log directory.
24016 @param[in] base File name without extension, such as `Continuity_Metrics`.
24017 @return Existing path, preferring `.csv`; None when neither exists.
24018 """
24019 for extension in (".csv", ".log"):
24020 path = os.path.join(log_dir, base + extension)
24021 if os.path.isfile(path):
24022 return path
24023 return None
24024
24025
24026def _block_family_paths(log_dir: str, *bases: str) -> "list[tuple[int, str]]":
24027 """!
24028 @brief Return `(block, path)` for each per-block solver record, CSV preferred per block.
24029 @param[in] log_dir Run log directory.
24030 @param[in] bases File-name stems before `_Block_<n>`, newest naming first.
24031 @return Sorted `(block, path)` pairs.
24032 """
24033 chosen = {}
24034 for base in bases:
24035 for extension in (".csv", ".log"):
24036 for path in glob.glob(os.path.join(log_dir, f"{base}_Block_*{extension}")):
24037 match = re.search(r"_Block_(\d+)\.(csv|log)$", path)
24038 if match and int(match.group(1)) not in chosen:
24039 chosen[int(match.group(1))] = path
24040 return sorted(chosen.items())
24041
24042
24043def _continuity_rows(log_dir: str) -> "tuple[str | None, list]":
24044 """!
24045 @brief Return the continuity record's path and its `(segment, row)` pairs.
24046 @param[in] log_dir Run log directory.
24047 @return Path (or None) and rows keyed step, block, max_divergence, max_divergence_location,
24048 rhs_sum, flux_in, flux_out, net_flux.
24049 """
24050 path = _log_family_path(log_dir, "Continuity_Metrics")
24051 rows = []
24052 if path is None:
24053 return None, rows
24054 if path.endswith(".csv"):
24055 for segment, row in _read_runtime_diagnostics_csv(path):
24056 step, block = _parse_int_loose(row.get("step")), _parse_int_loose(row.get("block"))
24057 if step is None or block is None:
24058 continue
24059 rows.append((segment, {
24060 "step": step, "block": block,
24061 "max_divergence": _parse_float_loose(row.get("max_divergence")),
24062 "max_divergence_location": "([{}][{}][{}] = {})".format(
24063 row.get("max_divergence_k"), row.get("max_divergence_j"),
24064 row.get("max_divergence_i"), row.get("max_divergence_index")),
24065 "rhs_sum": _parse_float_loose(row.get("poisson_source_imbalance")),
24066 "flux_in": _parse_float_loose(row.get("flux_in")),
24067 "flux_out": _parse_float_loose(row.get("flux_out")),
24068 "net_flux": _parse_float_loose(row.get("net_flux")),
24069 }))
24070 return path, rows
24071 segment = 0
24072 with open(path, "r", encoding="utf-8", errors="replace") as f:
24073 for raw_line in f:
24075 segment += 1
24076 continue
24077 line = raw_line.strip()
24078 if not line or line.startswith("-") or line.startswith("Timestep"):
24079 continue
24080 parts = [part.strip() for part in raw_line.split("|")]
24081 if len(parts) < 8:
24082 continue
24083 step, block = _parse_int_loose(parts[0]), _parse_int_loose(parts[1])
24084 if step is None or block is None:
24085 continue
24086 rows.append((segment, {
24087 "step": step, "block": block,
24088 "max_divergence": _parse_float_loose(parts[2]),
24089 "max_divergence_location": parts[3],
24090 "rhs_sum": _parse_float_loose(parts[4]),
24091 "flux_in": _parse_float_loose(parts[5]),
24092 "flux_out": _parse_float_loose(parts[6]),
24093 "net_flux": _parse_float_loose(parts[7]),
24094 }))
24095 return path, rows
24096
24097
24098def _particle_step_rows(log_dir: str, metrics_dir: str) -> "tuple[str | None, list]":
24099 """!
24100 @brief Return the per-step particle record's path and its `(segment, row)` pairs.
24101
24102 `search_metrics.csv` carries the step's particle counts; a run that predates it in that
24103 role wrote `Particle_Metrics.log`, read when the CSV is absent.
24104
24105 @param[in] log_dir Run log directory.
24106 @param[in] metrics_dir Run analysis metrics directory.
24107 @return Path (or None) and rows keyed step, total_particles, lost_particles,
24108 lost_particles_cumulative, migrated_particles, occupied_cells, load_imbalance,
24109 migration_passes, exited_particles, exited_particles_cumulative.
24110 """
24111 rows = []
24112 csv_path = os.path.join(metrics_dir, "search_metrics.csv") if metrics_dir else None
24113 if csv_path and os.path.isfile(csv_path):
24114 for segment, row in _read_runtime_diagnostics_csv(csv_path):
24115 step = _parse_int_loose(row.get("step"))
24116 if step is None:
24117 continue
24118 rows.append((segment, {
24119 "step": step,
24120 "total_particles": _parse_int_loose(row.get("total_particles")),
24121 "lost_particles": _parse_int_loose(row.get("lost")),
24122 "lost_particles_cumulative": _parse_int_loose(row.get("lost_cumulative")),
24123 "migrated_particles": _parse_int_loose(row.get("migrated")),
24124 "occupied_cells": _parse_int_loose(row.get("occupied_cells")),
24125 "load_imbalance": _parse_float_loose(row.get("load_imbalance")),
24126 "migration_passes": _parse_int_loose(row.get("migration_passes")),
24127 "exited_particles": _parse_int_loose(row.get("exited")),
24128 "exited_particles_cumulative": _parse_int_loose(row.get("exited_cumulative")),
24129 }))
24130 return csv_path, rows
24131 path = os.path.join(log_dir, "Particle_Metrics.log")
24132 if not os.path.isfile(path):
24133 return None, rows
24134 segment = 0
24135 with open(path, "r", encoding="utf-8", errors="replace") as f:
24136 for raw_line in f:
24138 segment += 1
24139 continue
24140 line = raw_line.strip()
24141 if not line or line.startswith("-") or line.startswith("Stage"):
24142 continue
24143 parts = [part.strip() for part in raw_line.split("|")]
24144 if len(parts) < 8:
24145 continue
24146 step = _parse_int_loose(parts[1])
24147 if step is None:
24148 continue
24149 offset = 1 if len(parts) >= 9 else 0
24150 rows.append((segment, {
24151 "step": step,
24152 "total_particles": _parse_int_loose(parts[2]),
24153 "lost_particles": _parse_int_loose(parts[3]),
24154 "lost_particles_cumulative": _parse_int_loose(parts[4]) if offset else None,
24155 "migrated_particles": _parse_int_loose(parts[4 + offset]),
24156 "occupied_cells": _parse_int_loose(parts[5 + offset]),
24157 "load_imbalance": _parse_float_loose(parts[6 + offset]),
24158 "migration_passes": _parse_int_loose(parts[7 + offset]),
24159 "exited_particles": _parse_int_loose(parts[9]) if len(parts) >= 11 else None,
24160 "exited_particles_cumulative": _parse_int_loose(parts[10]) if len(parts) >= 11 else None,
24161 }))
24162 return path, rows
24163
24164
24165_LEGACY_PICARD_REGEX = re.compile(
24166 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\s*\|"
24167 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
24168 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
24169 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
24170 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
24171 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
24172 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
24173 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
24174 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
24175)
24176
24177
24178def _picard_history_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24179 """!
24180 @brief Return `(block, path, [(segment, row)])` for each Picard momentum history.
24181 @param[in] log_dir Run log directory.
24182 @return Rows keyed step, block, pseudo_iterations, dtau, cfl_eff, delta_norm, delta_rel,
24183 residual_norm, residual_rel, trial_ratio, smoothed_ratio, status, dtau_after,
24184 cfl_eff_after; dtau is solver time and cfl_eff = dtau * lambda_max.
24185 """
24186 out = []
24187 for block, path in _block_family_paths(log_dir, "Momentum_Solver_DualTime_Picard_Jameson_RK_History",
24188 "Momentum_Solver_Convergence_History"):
24189 rows = []
24190 if path.endswith(".csv"):
24191 for segment, row in _read_runtime_diagnostics_csv(path):
24192 step = _parse_int_loose(row.get("step"))
24193 if step is None:
24194 continue
24195 rows.append((segment, {
24196 "step": step, "block": block,
24197 "pseudo_iterations": _parse_int_loose(row.get("pseudo_iter")),
24198 "dtau": _parse_float_loose(row.get("dtau")),
24199 "cfl_eff": _parse_float_loose(row.get("cfl_eff")),
24200 "delta_norm": _parse_float_loose(row.get("delta_norm")),
24201 "delta_rel": _parse_float_loose(row.get("delta_rel")),
24202 "residual_norm": _parse_float_loose(row.get("residual_norm")),
24203 "residual_rel": _parse_float_loose(row.get("residual_rel")),
24204 "trial_ratio": _parse_float_loose(row.get("trial_ratio")),
24205 "smoothed_ratio": _parse_float_loose(row.get("smoothed_ratio")),
24206 "status": row.get("status") or None,
24207 "dtau_after": _parse_float_loose(row.get("dtau_after")),
24208 "cfl_eff_after": _parse_float_loose(row.get("cfl_eff_after")),
24209 }))
24210 else:
24211 segment = 0
24212 with open(path, "r", encoding="utf-8", errors="replace") as f:
24213 for raw_line in f:
24215 segment += 1
24216 continue
24217 match = _LEGACY_PICARD_REGEX.search(raw_line)
24218 if not match:
24219 continue
24220 rows.append((segment, {
24221 "step": int(match.group("step")), "block": block,
24222 "pseudo_iterations": int(match.group("pseudo_iter")),
24223 "dtau": _parse_float_loose(match.group("dtau")),
24224 "cfl_eff": _parse_float_loose(match.group("cfl_eff")),
24225 "delta_norm": float(match.group("delta")),
24226 "delta_rel": float(match.group("delta_rel")),
24227 "residual_norm": float(match.group("resid")),
24228 "residual_rel": float(match.group("resid_rel")),
24229 "trial_ratio": _parse_float_loose(match.group("trial_ratio")),
24230 "smoothed_ratio": _parse_float_loose(match.group("smoothed_ratio")),
24231 "status": match.group("status"),
24232 "dtau_after": _parse_float_loose(match.group("dtau_after")),
24233 "cfl_eff_after": _parse_float_loose(match.group("cfl_eff_after")),
24234 }))
24235 out.append((block, path, rows))
24236 return out
24237
24238
24239_LEGACY_NEWTON_HISTORY_REGEX = re.compile(
24240 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
24241 r"\s*newton:\s*(?P<newton>\d+)\s*\|\s*nonlinear_norm:\s*(?P<norm>[-+0-9.eE]+)"
24242)
24243_LEGACY_NEWTON_SUMMARY_REGEX = re.compile(
24244 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
24245 r"\s*solver:\s*(?P<solver>[^|]+?)\s*\|"
24246 r"(?:\s*Jacobian:\s*[^|]+?\s*\|\s*Preconditioner:\s*[^|]+?\s*\|)?"
24247 r"\s*reason:\s*(?P<reason>\S+)\s*\|"
24248 r"\s*reason_code:\s*(?P<reason_code>-?\d+)\s*\|\s*newton:\s*(?P<newton>\d+)\s*\|"
24249 r"\s*evals:\s*(?P<evals>\d+)\s*\|\s*krylov:\s*(?P<krylov>\d+)\s*\|"
24250 r"\s*initial:\s*(?P<initial>[-+0-9.eE]+|unavailable)\s*\|"
24251 r"\s*final:\s*(?P<final>[-+0-9.eE]+)\s*\|\s*state:\s*(?P<state>\w+)"
24252)
24253
24254
24255def _newton_history_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24256 """!
24257 @brief Return `(block, path, [(segment, row)])` for each Newton iteration history.
24258 @param[in] log_dir Run log directory.
24259 @return Rows keyed step, block, newton_iterations, residual_norm.
24260 """
24261 out = []
24262 for block, path in _block_family_paths(log_dir, "Momentum_Solver_Newton_Krylov_History"):
24263 rows = []
24264 if path.endswith(".csv"):
24265 for segment, row in _read_runtime_diagnostics_csv(path):
24266 step = _parse_int_loose(row.get("step"))
24267 if step is not None:
24268 rows.append((segment, {"step": step, "block": _parse_int_loose(row.get("block")),
24269 "newton_iterations": _parse_int_loose(row.get("newton")),
24270 "residual_norm": _parse_float_loose(row.get("nonlinear_norm"))}))
24271 else:
24272 segment = 0
24273 with open(path, "r", encoding="utf-8", errors="replace") as f:
24274 for raw_line in f:
24276 segment += 1
24277 continue
24278 match = _LEGACY_NEWTON_HISTORY_REGEX.search(raw_line)
24279 if match:
24280 rows.append((segment, {"step": int(match.group("step")), "block": int(match.group("block")),
24281 "newton_iterations": int(match.group("newton")),
24282 "residual_norm": float(match.group("norm"))}))
24283 out.append((block, path, rows))
24284 return out
24285
24286
24287def _newton_summary_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24288 """!
24289 @brief Return `(block, path, [(segment, row)])` for each Newton per-step summary.
24290 @param[in] log_dir Run log directory.
24291 @return Rows keyed step, block, solver, reason, reason_code, newton_iterations,
24292 residual_evaluations, krylov_iterations, initial_norm, final_norm, state.
24293 """
24294 out = []
24295 for block, path in _block_family_paths(log_dir, "Momentum_Solver_Newton_Krylov_Summary"):
24296 rows = []
24297 if path.endswith(".csv"):
24298 for segment, row in _read_runtime_diagnostics_csv(path):
24299 step = _parse_int_loose(row.get("step"))
24300 if step is None:
24301 continue
24302 rows.append((segment, {
24303 "step": step, "block": _parse_int_loose(row.get("block")),
24304 "solver": row.get("solver"), "reason": row.get("reason"),
24305 "reason_code": _parse_int_loose(row.get("reason_code")),
24306 "newton_iterations": _parse_int_loose(row.get("newton")),
24307 "residual_evaluations": _parse_int_loose(row.get("evals")),
24308 "krylov_iterations": _parse_int_loose(row.get("krylov")),
24309 "initial_norm": _parse_float_loose(row.get("initial_norm")),
24310 "final_norm": _parse_float_loose(row.get("final_norm")),
24311 "state": row.get("state"),
24312 }))
24313 else:
24314 segment = 0
24315 with open(path, "r", encoding="utf-8", errors="replace") as f:
24316 for raw_line in f:
24318 segment += 1
24319 continue
24320 match = _LEGACY_NEWTON_SUMMARY_REGEX.search(raw_line)
24321 if not match:
24322 continue
24323 initial_text = match.group("initial")
24324 rows.append((segment, {
24325 "step": int(match.group("step")), "block": int(match.group("block")),
24326 "solver": match.group("solver").strip(), "reason": match.group("reason"),
24327 "reason_code": int(match.group("reason_code")),
24328 "newton_iterations": int(match.group("newton")),
24329 "residual_evaluations": int(match.group("evals")),
24330 "krylov_iterations": int(match.group("krylov")),
24331 "initial_norm": None if initial_text == "unavailable" else float(initial_text),
24332 "final_norm": float(match.group("final")),
24333 "state": match.group("state"),
24334 }))
24335 out.append((block, path, rows))
24336 return out
24337
24338
24339_LEGACY_POISSON_REGEX = re.compile(
24340 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
24341 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
24342 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
24343)
24344
24345
24346def _poisson_history_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24347 """!
24348 @brief Return `(block, path, [(segment, row)])` for each Poisson iteration history.
24349 @param[in] log_dir Run log directory.
24350 @return Rows keyed step, block, iterations, unpreconditioned_norm, true_norm, relative_norm;
24351 true_norm and relative_norm are None unless the run computed the true residual.
24352 """
24353 out = []
24354 for block, path in _block_family_paths(log_dir, "Poisson_Solver_Convergence_History"):
24355 rows = []
24356 if path.endswith(".csv"):
24357 for segment, row in _read_runtime_diagnostics_csv(path):
24358 step = _parse_int_loose(row.get("step"))
24359 if step is not None:
24360 rows.append((segment, {"step": step, "block": _parse_int_loose(row.get("block")),
24361 "iterations": _parse_int_loose(row.get("iter")),
24362 "unpreconditioned_norm": _parse_float_loose(row.get("unpreconditioned_norm")),
24363 "true_norm": _parse_float_loose(row.get("true_norm")),
24364 "relative_norm": _parse_float_loose(row.get("relative_norm"))}))
24365 else:
24366 segment = 0
24367 with open(path, "r", encoding="utf-8", errors="replace") as f:
24368 for raw_line in f:
24370 segment += 1
24371 continue
24372 match = _LEGACY_POISSON_REGEX.search(raw_line)
24373 if match:
24374 rows.append((segment, {"step": int(match.group("step")), "block": int(match.group("block")),
24375 "iterations": int(match.group("iter")),
24376 "unpreconditioned_norm": float(match.group("unpre")),
24377 "true_norm": float(match.group("true")),
24378 "relative_norm": _parse_float_loose(match.group("rel"))}))
24379 out.append((block, path, rows))
24380 return out
24381
24382
24383def _runtime_memory_rows(path: str) -> list:
24384 """!
24385 @brief Return the runtime memory record's `(segment, row)` pairs (CSV, or an older run's text log).
24386 @param[in] path Runtime memory log path as configured.
24387 @return Rows keyed step, event, process_current_mb_max, process_peak_mb_max,
24388 petsc_allocated_mb_max, petsc_peak_allocated_mb_max, process_change_mb_max, reason.
24389 """
24390 rows = []
24391 if not path or not os.path.isfile(path):
24392 return rows
24393 keys = ("process_current_mb_max", "process_peak_mb_max", "petsc_allocated_mb_max",
24394 "petsc_peak_allocated_mb_max", "process_change_mb_max")
24395 with open(path, "r", encoding="utf-8", errors="replace") as f:
24396 first = ""
24397 for line in f:
24398 if line.strip() and not line.lstrip().startswith("#"):
24399 first = line
24400 break
24401 if first.startswith("step,"):
24402 for segment, row in _read_runtime_diagnostics_csv(path):
24403 step = _parse_int_loose(row.get("step"))
24404 if step is None:
24405 continue
24406 rows.append((segment, {"step": step, "event": row.get("event"), "reason": row.get("reason"),
24407 "step_wall_s": _parse_float_loose(row.get("step_wall_s")),
24408 **{key: _parse_float_loose(row.get(key)) for key in keys}}))
24409 return rows
24410 segment = 0
24411 with open(path, "r", encoding="utf-8", errors="replace") as f:
24412 for raw_line in f:
24414 segment += 1
24415 continue
24416 line = raw_line.strip()
24417 if not line or line.startswith("#") or line.startswith("Step"):
24418 continue
24419 parts = line.split()
24420 step = _parse_int_loose(parts[0]) if parts else None
24421 if len(parts) < 8 or step is None:
24422 continue
24423 rows.append((segment, {"step": step, "event": parts[1], "reason": parts[7],
24424 **{key: _parse_float_loose(parts[2 + i]) for i, key in enumerate(keys)}}))
24425 return rows
24426
24427
24428# Columns that identify a plot record (its step and line) rather than carry a value; text
24429# columns such as a status or an event are dropped by _append_summary_plot_record() itself.
24430_PLOT_ROW_IDENTITY_KEYS = ("step", "block")
24431
24432
24433def _typed_csv_value(text: str):
24434 """!
24435 @brief Return a CSV cell as a float (written with a point or exponent), an int, or the text.
24436 @param[in] text Cell text.
24437 @return Parsed value.
24438 """
24439 float_val = _parse_float_loose(text)
24440 int_val = _parse_int_loose(text)
24441 if float_val is not None and ("." in text or "e" in text.lower()):
24442 return float_val
24443 if int_val is not None:
24444 return int_val
24445 return text
24446
24447
24448def _solution_convergence_rows(log_dir: str) -> "tuple[str | None, list]":
24449 """!
24450 @brief Return the solution-convergence record's path and its `(segment, row)` pairs.
24451 @param[in] log_dir Run log directory.
24452 @return Path (or None) and rows with every column, numbers parsed and `mode` set.
24453 """
24454 path = _log_family_path(log_dir, "solution_convergence")
24455 rows = []
24456 if path is None:
24457 return None, rows
24458
24459 if path.endswith(".csv"):
24460 for segment, row in _read_runtime_diagnostics_csv(path):
24461 step = _parse_int_loose(row.get("step"))
24462 if step is None:
24463 continue
24464 values = {name: _typed_csv_value(text) for name, text in row.items() if name}
24465 values["step"] = step
24466 rows.append((segment, values))
24467 return path, rows
24468 mode, col_names, segment = None, None, 0
24469 with open(path, "r", encoding="utf-8", errors="replace") as f:
24470 for raw_line in f:
24471 line = raw_line.strip()
24473 segment += 1
24474 continue
24475 if not line:
24476 continue
24477 if line.startswith("="):
24478 found = re.search(r"\[mode:\s*([\w_]+)", line)
24479 if found:
24480 mode = found.group(1)
24481 continue
24482 if line.startswith("-"):
24483 continue
24484 if col_names is None:
24485 col_names = [p.strip() for p in raw_line.split("|")]
24486 continue
24487 parts = [p.strip() for p in raw_line.split("|")]
24488 step = _parse_int_loose(parts[0]) if col_names[0] == "step" and len(parts) >= 4 else None
24489 if step is None:
24490 continue
24491 values = {"mode": mode}
24492 values.update({name: _typed_csv_value(text) for name, text in zip(col_names, parts) if name})
24493 values["step"] = step
24494 rows.append((segment, values))
24495 return path, rows
24496
24497
24498def _append_summary_plot_record(records: list, source: str, step, line: str, values: dict,
24499 source_path: str, segment: int = 0, coordinates: dict = None):
24500 """!
24501 @brief Append one numeric append-ordered record for summarize plotting.
24502 @param[out] records Destination record list.
24503 @param[in] source Qualified source prefix.
24504 @param[in] step Logged timestep.
24505 @param[in] line Human-readable line identity.
24506 @param[in] values Candidate field mapping.
24507 @param[in] source_path Source artifact path.
24508 @param[in] segment Zero-based continuation segment within the source artifact.
24509 @param[in] coordinates Optional independent variables carried by the source row.
24510 """
24511 numeric = {
24512 key: value
24513 for key, value in values.items()
24514 if isinstance(value, (int, float)) and not isinstance(value, bool)
24515 }
24516 if step is not None and numeric:
24517 records.append({
24518 "source": source,
24519 "step": int(step),
24520 "line": line,
24521 "values": numeric,
24522 "source_path": source_path,
24523 "segment": int(segment),
24524 "coordinates": {
24525 key: value for key, value in (coordinates or {}).items()
24526 if isinstance(value, (int, float)) and not isinstance(value, bool)
24527 },
24528 })
24529
24530
24532 """!
24533 @brief Return whether a log line starts a new continuation segment.
24534 @param[in] line Candidate raw or stripped log line.
24535 @return True for the shared continuation marker syntax.
24536 """
24537 return bool(re.match(r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
24538
24539
24540def _collect_summary_plot_records(context: dict) -> list:
24541 """!
24542 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
24543 @param[in] context Summary context returned by `_build_summary_context()`.
24544 @return Append-ordered plot record list.
24545 """
24546 records = []
24547 log_dir = context["log_dir"]
24548 # The C runtime writes its per-step diagnostics CSVs to the analysis metrics home,
24549 # not beside the text logs; PicurvOpenDiagnosticsCsv() owns that placement.
24550 metrics_dir = context["metrics_dir"]
24551
24552 continuity_path, continuity_rows = _continuity_rows(log_dir)
24553 for segment, row in continuity_rows:
24555 records, "continuity", row["step"], f"block {row['block']}",
24556 {key: row[key] for key in ("max_divergence", "rhs_sum", "flux_in", "flux_out", "net_flux")},
24557 continuity_path, segment,
24558 )
24559
24560 particle_path, particle_rows = _particle_step_rows(log_dir, metrics_dir)
24561 for segment, row in particle_rows:
24563 records, "particles", row["step"], "particles",
24564 {key: value for key, value in row.items() if key != "step"},
24565 particle_path, segment,
24566 )
24567
24568 # dtau is solver time; cfl_eff = dtau * lambda_max, the Courant number pseudo_cfl.* controls.
24569 for block, path, rows in _picard_history_rows(log_dir):
24570 for segment, row in rows:
24572 records, "momentum", row["step"], f"block {block}",
24573 {key: value for key, value in row.items() if key not in _PLOT_ROW_IDENTITY_KEYS},
24574 path, segment,
24575 coordinates={"solver_iteration": row["pseudo_iterations"]},
24576 )
24577
24578 for block, path, rows in _newton_history_rows(log_dir):
24579 for segment, row in rows:
24581 records, "momentum", row["step"], f"block {row['block']}",
24582 {"newton_iterations": row["newton_iterations"], "residual_norm": row["residual_norm"]},
24583 path, segment,
24584 coordinates={"solver_iteration": row["newton_iterations"]},
24585 )
24586
24587 for block, path, rows in _poisson_history_rows(log_dir):
24588 for segment, row in rows:
24590 records, "poisson", row["step"], f"block {row['block']}",
24591 {key: row[key] for key in ("iterations", "unpreconditioned_norm", "true_norm", "relative_norm")},
24592 path, segment,
24593 coordinates={"solver_iteration": row["iterations"]},
24594 )
24595
24596 # The LES coefficient history. cs_effective is the curve an LES run is judged on:
24597 # on the HOM02 benchmark at 64^3 it settles at 0.18-0.19 (page 72, section 10).
24598 les_path = os.path.join(metrics_dir, "les_coefficient.csv")
24599 if os.path.isfile(les_path):
24600 for segment, row in _read_runtime_diagnostics_csv(les_path):
24602 records, "les", _parse_int_loose(row.get("step")), "coefficient",
24603 {
24604 "cs_effective": _parse_float_loose(row.get("cs_effective")),
24605 "cs_mean": _parse_float_loose(row.get("cs_mean")),
24606 "coefficient_rms": _parse_float_loose(row.get("coefficient_rms")),
24607 "coefficient_min": _parse_float_loose(row.get("coefficient_min")),
24608 "coefficient_max": _parse_float_loose(row.get("coefficient_max")),
24609 "nu_t_mean": _parse_float_loose(row.get("nu_t_mean")),
24610 "nu_t_max": _parse_float_loose(row.get("nu_t_max")),
24611 "nu_t_over_nu_mean": _parse_float_loose(row.get("nu_t_over_nu_mean")),
24612 "k_sgs_mean": _parse_float_loose(row.get("k_sgs_mean")),
24613 "backscatter_fraction": _parse_float_loose(row.get("backscatter_fraction")),
24614 "limited_fraction": _parse_float_loose(row.get("limited_fraction")),
24615 },
24616 les_path, segment,
24617 )
24618
24619 # The wall-model history. y_plus_mean is what says whether the first cell sits where
24620 # the selected law is valid; u_tau is what a channel run is scored against.
24621 wall_path = os.path.join(metrics_dir, "wall_model.csv")
24622 if os.path.isfile(wall_path):
24623 for segment, row in _read_runtime_diagnostics_csv(wall_path):
24625 records, "wall_model", _parse_int_loose(row.get("step")), "near wall",
24626 {
24627 "u_tau_mean": _parse_float_loose(row.get("u_tau_mean")),
24628 "u_tau_rms": _parse_float_loose(row.get("u_tau_rms")),
24629 "u_tau_min": _parse_float_loose(row.get("u_tau_min")),
24630 "u_tau_max": _parse_float_loose(row.get("u_tau_max")),
24631 "y_plus_mean": _parse_float_loose(row.get("y_plus_mean")),
24632 "y_plus_max": _parse_float_loose(row.get("y_plus_max")),
24633 "wall_distance_mean": _parse_float_loose(row.get("wall_distance_mean")),
24634 "nu_wall_over_nu_mean": _parse_float_loose(row.get("nu_wall_over_nu_mean")),
24635 "wall_cells": _parse_int_loose(row.get("wall_cells")),
24636 },
24637 wall_path, segment,
24638 )
24639
24640 # The driven-flow controller history. driving_acceleration is the force per unit mass
24641 # the flow received; in a stationary driven channel its time average is u_tau^2 / h.
24642 driven_path = os.path.join(metrics_dir, "driven_flow.csv")
24643 if os.path.isfile(driven_path):
24644 for segment, row in _read_runtime_diagnostics_csv(driven_path):
24646 records, "driven_flow", _parse_int_loose(row.get("step")), "controller",
24647 {
24648 "target_flux": _parse_float_loose(row.get("target_flux")),
24649 "measured_flux": _parse_float_loose(row.get("measured_flux")),
24650 "bulk_velocity": _parse_float_loose(row.get("bulk_velocity")),
24651 "bulk_velocity_correction": _parse_float_loose(row.get("bulk_velocity_correction")),
24652 "driving_acceleration": _parse_float_loose(row.get("driving_acceleration")),
24653 },
24654 driven_path, segment,
24655 )
24656
24657 profiling_path = os.path.join(log_dir, PROFILING_TIMESTEP_FILE)
24658 if os.path.isfile(profiling_path):
24659 segment = 0
24660 columns = None
24661 with open(profiling_path, "r", encoding="utf-8", errors="replace", newline="") as f:
24662 for raw_line in f:
24664 segment += 1
24665 continue
24666 values = next(csv.reader([raw_line]))
24667 if not values:
24668 continue
24669 if columns is None:
24670 columns = values
24671 continue
24672 row = dict(zip(columns, values))
24674 records, "profiling", _parse_int_loose(row.get("step")), row.get("function") or "unknown",
24675 {"calls": _parse_int_loose(row.get("calls")), "step_time_s": _parse_float_loose(row.get("step_time_s"))},
24676 profiling_path, segment,
24677 )
24678
24679 memory_path = _log_family_path(log_dir, "Runtime_Memory") or os.path.join(log_dir, RUNTIME_MEMORY_FILE)
24680 for segment, row in _runtime_memory_rows(memory_path):
24681 if row["event"] in {"Step", "Post"}:
24683 records, "memory", row["step"], "memory",
24684 {key: value for key, value in row.items() if key not in _PLOT_ROW_IDENTITY_KEYS},
24685 memory_path, segment,
24686 )
24687
24688 convergence_path, convergence_rows = _solution_convergence_rows(log_dir)
24689 for segment, row in convergence_rows:
24691 records, "convergence", row["step"], "convergence",
24692 {name: value for name, value in row.items()
24693 if name not in {"step", "time", "mode", "ref", "ph", "per", "win"}},
24694 convergence_path, segment,
24695 coordinates={"time": row.get("time") if isinstance(row.get("time"), (int, float)) else None},
24696 )
24697
24698 records.extend(_collect_spectra_plot_records(context))
24699 records.extend(_collect_profile_plot_records(context))
24700 return records
24701
24702
24703def _collect_spectra_plot_records(context: dict) -> list:
24704 """!
24705 @brief Collect the per-step scalar histories written by the spectra post stage.
24706
24707 @details Each spectra task keeps its own history file, so one task becomes one
24708 plotted line and several tasks compare directly on the same axes.
24709
24710 @param[in] context Summary context returned by `_build_summary_context()`.
24711 @return Append-ordered plot record list; empty when no spectra were measured.
24712 """
24713 records = []
24714 run_dir = context.get("run_dir")
24715 if not run_dir:
24716 return records
24717 spectra_root = os.path.join(
24718 run_dir, resolve_post_spectra_output_dir(context.get("monitor_cfg") or {})
24719 )
24720 for root, _dirs, files in os.walk(spectra_root):
24721 for filename in sorted(files):
24722 if not filename.endswith("_history.csv"):
24723 continue
24724 history_path = os.path.join(root, filename)
24725 line = filename[: -len("_history.csv")]
24726 try:
24727 with open(history_path, "r", encoding="utf-8", errors="replace") as stream:
24728 for row in csv.DictReader(stream):
24729 step = _parse_int_loose(row.get("step"))
24730 if step is None:
24731 continue
24732 values = {}
24733 for column in POST_SPECTRA_SCALAR_COLUMNS:
24734 parsed = _parse_float_loose(row.get(column))
24735 if parsed is not None:
24736 values[column] = parsed
24737 if values:
24739 records, "spectra", step, line, values, history_path, 0,
24740 coordinates={"time": _parse_float_loose(row.get("time"))},
24741 )
24742 except OSError:
24743 # A history a concurrent post stage is still writing is simply not
24744 # listed yet; summarize is read-only and must never fail on it.
24745 continue
24746 return records
24747
24748
24749def _collect_profile_plot_records(context: dict) -> list:
24750 """!
24751 @brief Collect the profile histories the `profile_series` reducer wrote.
24752
24753 @details Each `<prefix>_profile_<name>_history.csv` holds one row per step and one
24754 column per `<output column>.<functional>`, which becomes the series
24755 `profile.<column>.<functional>`, one line per history file. The functionals
24756 are the reducer's (`generators/reduce.gen profile-series`); this only reads.
24757
24758 @param[in] context Summary context returned by `_build_summary_context()`.
24759 @return Append-ordered plot record list; empty when no history was written.
24760 """
24761 records = []
24762 run_dir = context.get("run_dir")
24763 if not run_dir:
24764 return records
24765 root_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["statistics"])
24766 for root, _dirs, files in os.walk(root_dir):
24767 for filename in sorted(files):
24768 if not re.match(r"^.+_profile_.+_history\.csv$", filename):
24769 continue
24770 path = os.path.join(root, filename)
24771 line = os.path.relpath(path, root_dir)[: -len("_history.csv")]
24772 try:
24773 with open(path, "r", encoding="utf-8", errors="replace") as stream:
24774 for row in csv.DictReader(stream):
24775 step = _parse_int_loose(row.pop("step", None))
24776 time = _parse_float_loose(row.pop("time", None))
24777 values = {key: _parse_float_loose(value) for key, value in row.items()}
24778 values = {key: value for key, value in values.items() if value is not None}
24779 if step is not None and values:
24780 _append_summary_plot_record(records, "profile", step, line, values, path, 0,
24781 coordinates={"time": time})
24782 except OSError:
24783 continue
24784 return records
24785
24786
24787def _build_summary_plot_catalog(records: list) -> list:
24788 """!
24789 @brief Build available qualified-series metadata from plot records.
24790 @param[in] records Append-ordered plot record list.
24791 @return Available series catalog.
24792 """
24793 catalog = {}
24794 for record in records:
24795 for field in record["values"]:
24796 name = f"{record['source']}.{field}"
24797 item = catalog.setdefault(name, {"series": name, "lines": {}, "source_paths": set(), "sample_count": 0})
24798 item["lines"][record["line"]] = item["lines"].get(record["line"], 0) + 1
24799 item["source_paths"].add(record["source_path"])
24800 item["sample_count"] += 1
24801 return [
24802 {
24803 **item,
24804 "lines": [{"label": label, "sample_count": count} for label, count in sorted(item["lines"].items())],
24805 "source_paths": sorted(item["source_paths"]),
24806 }
24807 for _, item in sorted(catalog.items())
24808 ]
24809
24810
24811def _representative_indices(count: int, maximum: int = 6) -> list:
24812 """!
24813 @brief Select evenly distributed indices while always retaining both endpoints.
24814 @param[in] count Number of available ordered states.
24815 @param[in] maximum Maximum states to retain.
24816 @return Sorted unique zero-based indices.
24817 """
24818 if count <= maximum:
24819 return list(range(count))
24820 return sorted({round(index * (count - 1) / (maximum - 1)) for index in range(maximum)})
24821
24822
24823def _build_spectrum_plot_request(context: dict, task: str, reference: bool,
24824 linear_y: bool, output_path: "str | None") -> dict:
24825 """!
24826 @brief Build a plot.gen request drawing representative measured spectra.
24827
24828 @details A decaying flow has a different spectrum at every step, so states are
24829 never averaged. Up to six evenly spaced states are retained, including
24830 the first and last, to show evolution without an unreadable legend.
24831
24832 @param[in] context Summary context returned by `_build_summary_context()`.
24833 @param[in] task Task basename, or a unique substring of one.
24834 @param[in] reference Overlay the staged initial-condition spectrum when available.
24835 @param[in] linear_y Force linear axes instead of the log-log default.
24836 @param[in] output_path Optional explicit output path.
24837 @return Versioned normalized plot request.
24838 @throws ValueError when no matching spectrum file exists.
24839 """
24840 spectra_dir = os.path.join(
24841 context["run_dir"],
24842 resolve_post_spectra_output_dir(context.get("monitor_cfg") or {}),
24843 )
24844 candidates = []
24845 if os.path.isdir(spectra_dir):
24846 # The staged initial-condition spectrum shares this directory but is the
24847 # reference overlay, never a task a user can select. Post recipes write into
24848 # their own subdirectory (resolve_recipe_spectra_output_dir), so one level is
24849 # searched too; a candidate is named by its path under the spectra directory.
24850 reference_name = os.path.basename(INITIAL_CONDITION_SPECTRUM_RELPATH)
24851 for root, _dirs, files in os.walk(spectra_dir):
24852 if os.path.relpath(root, spectra_dir).count(os.sep) > 0:
24853 continue
24854 for name in files:
24855 if (name.endswith(".csv") and not name.endswith("_history.csv")
24856 and name != reference_name):
24857 candidates.append(os.path.relpath(os.path.join(root, name), spectra_dir))
24858 candidates.sort()
24859 if not candidates:
24860 raise ValueError(
24861 "No spectra were found for this run. Measure them with a post.yml that defines "
24862 "spectra tasks: 'picurv run --post-process --only spectra --run-dir <run> --post <post.yml>'."
24863 )
24864 matches = [name for name in candidates if task in name] if task else candidates
24865 if len(matches) != 1:
24866 available = ", ".join(name[: -len(".csv")] for name in candidates)
24867 raise ValueError(
24868 f"Spectrum selector {task!r} matched {len(matches)} files. Available: {available}."
24869 )
24870 spectrum_path = os.path.join(spectra_dir, matches[0])
24871 if "plane_spectrum" in matches[0]:
24872 raise ValueError("Plane spectra have two wavenumber axes; use the spectrum CSV for a 2D plot.")
24873
24874 by_step = {}
24875 times = {}
24876 with open(spectrum_path, "r", encoding="utf-8", errors="replace") as stream:
24877 for row in csv.DictReader(stream):
24878 step = _parse_int_loose(row.get("step"))
24879 wavenumber = _parse_float_loose(row.get("k"))
24880 energy = _parse_float_loose(row.get("energy"))
24881 if step is None or wavenumber is None or energy is None:
24882 continue
24883 # A log-log plot cannot carry the zero wavenumber or an empty shell.
24884 if wavenumber <= 0.0 or (not linear_y and energy <= 0.0):
24885 continue
24886 by_step.setdefault(step, []).append([wavenumber, energy])
24887 times.setdefault(step, _parse_float_loose(row.get("time")))
24888 if not by_step:
24889 raise ValueError(f"{os.path.relpath(spectrum_path)} carries no plottable spectrum rows.")
24890
24891 lines = []
24892 if reference:
24893 reference_path = os.path.join(context["run_dir"], INITIAL_CONDITION_SPECTRUM_RELPATH)
24894 if os.path.isfile(reference_path):
24895 points = []
24896 with open(reference_path, "r", encoding="utf-8", errors="replace") as stream:
24897 for row in csv.DictReader(stream):
24898 wavenumber = _parse_float_loose(row.get("k"))
24899 energy = _parse_float_loose(row.get("energy"))
24900 if wavenumber is None or energy is None:
24901 continue
24902 if wavenumber <= 0.0 or (not linear_y and energy <= 0.0):
24903 continue
24904 points.append([wavenumber, energy])
24905 if points:
24906 lines.append({
24907 "label": "Initial condition", "points": sorted(points),
24908 "role": "reference", "color": "#333333", "line_style": "--",
24909 })
24910
24911 ordered_steps = sorted(by_step)
24912 selected_steps = [ordered_steps[index] for index in _representative_indices(len(ordered_steps))]
24913 spectrum_colors = ("#440154", "#414487", "#2A788E", "#22A884", "#7AD151", "#DCE319")
24914 for index, step in enumerate(selected_steps):
24915 time = times.get(step)
24916 label = f"Step {step}" if time is None else f"t = {time:g} (step {step})"
24917 lines.append({
24918 "label": label,
24919 "points": sorted(by_step[step]),
24920 "role": "latest" if step == ordered_steps[-1] else "snapshot",
24921 "color": spectrum_colors[round(index * (len(spectrum_colors) - 1) / max(1, len(selected_steps) - 1))],
24922 "line_width": 2.4 if step == ordered_steps[-1] else 1.65,
24923 })
24924
24925 name = os.path.basename(matches[0])[: -len(".csv")]
24926 fallback = os.path.join(
24927 context["run_dir"], CANONICAL_RUN_PATHS["plots"], f"{name}.png"
24928 )
24929 task_match = re.search(r"_(?P<field>[^_]+)_block(?P<block>\d+)_(?P<symbol>[^_]+)$", name)
24930 subtitle = None
24931 if task_match:
24932 subtitle = (
24933 f"Field {task_match.group('field')} · block {int(task_match.group('block'))} "
24934 f"· {task_match.group('symbol')} wavenumber"
24935 )
24936 return {
24937 "schema_version": 1,
24938 "plot_type": "spectrum",
24939 "series": name,
24940 "title": "Turbulent kinetic-energy spectrum",
24941 "subtitle": subtitle,
24942 "x_label": "Wavenumber, k",
24943 "y_label": "Energy spectrum, E(k)",
24944 "x_scale": "linear" if linear_y else "log",
24945 "y_scale": "linear" if linear_y else "log",
24946 "legend_title": "Snapshot",
24947 "show_markers": False,
24948 "window": {
24949 "mode": "representative", "last": None,
24950 "available_steps": len(ordered_steps), "selected_steps": selected_steps,
24951 },
24952 "lines": lines,
24953 "output_path": os.path.abspath(output_path) if output_path else fallback,
24954 }
24955
24956
24957def _build_summary_plot_request(context: dict, records: list, series: str, last_n: "int | None", linear_y: bool, output_path: "str | None") -> dict:
24958 """!
24959 @brief Build one normalized plot.gen request from collected summarize records.
24960 @param[in] context Summary context returned by `_build_summary_context()`.
24961 @param[in] records Append-ordered plot record list.
24962 @param[in] series Qualified series name.
24963 @param[in] last_n Optional last-N records per plotted line.
24964 @param[in] linear_y Whether to force linear scaling.
24965 @param[in] output_path Optional explicit output path.
24966 @return Versioned normalized plot request.
24967 """
24968 source, separator, field = series.partition(".")
24969 if not separator:
24970 raise ValueError("plot series must be qualified as '<source>.<field>'")
24971 matching = [record for record in records if record["source"] == source and field in record["values"]]
24972 if not matching:
24973 raise ValueError(f"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
24974 latest_segments = {}
24975 for record in matching:
24976 source_path = record["source_path"]
24977 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get("segment", 0))
24978 matching = [
24979 record for record in matching
24980 if record.get("segment", 0) == latest_segments[record["source_path"]]
24981 ]
24982 is_iteration_history = (
24983 source in {"momentum", "poisson"} and field in _SUMMARY_ITERATION_HISTORY_FIELDS
24984 and any("solver_iteration" in record.get("coordinates", {}) for record in matching)
24985 )
24986 grouped = {}
24987 selected_steps = set()
24988 if is_iteration_history:
24989 histories = {}
24990 for record in matching:
24991 iteration = record.get("coordinates", {}).get("solver_iteration")
24992 if iteration is None:
24993 continue
24994 histories.setdefault((record["source_path"], record["line"]), []).append(record)
24995 for (_source_path, label), history in histories.items():
24996 latest_step = max(record["step"] for record in history)
24997 selected_steps.add(latest_step)
24998 points = [
24999 [record["coordinates"]["solver_iteration"], record["values"][field]]
25000 for record in history if record["step"] == latest_step
25001 ]
25002 if last_n is not None:
25003 points = points[-last_n:]
25004 grouped.setdefault(f"{label} · step {latest_step}", []).extend(points)
25005 x_label = "Nonlinear iteration" if source == "momentum" else "Linear iteration"
25006 x_kind = "integer"
25007 else:
25008 uses_physical_time = all(_summary_physical_time(context, record) is not None for record in matching)
25009 for record in matching:
25010 x_value = _summary_physical_time(context, record) if uses_physical_time else record["step"]
25011 grouped.setdefault(record["line"], []).append([x_value, record["values"][field]])
25012 # Iteration-count fields occur on every nonlinear-history row. Reduce them
25013 # to the final effort per physical step instead of drawing a y=x staircase.
25014 if field in {"pseudo_iterations", "newton_iterations", "iterations"}:
25015 grouped = {
25016 label: [list(item) for item in {
25017 point[0]: max(candidate[1] for candidate in points if candidate[0] == point[0])
25018 for point in points
25019 }.items()]
25020 for label, points in grouped.items()
25021 }
25022 for label, points in grouped.items():
25023 points.sort(key=lambda point: point[0])
25024 if last_n is not None:
25025 grouped[label] = points[-last_n:]
25026 x_label = "Physical time" if uses_physical_time else "Physical timestep"
25027 x_kind = "continuous" if uses_physical_time else "integer"
25028
25029 grouped = {label: points for label, points in grouped.items() if points}
25030 if not grouped:
25031 raise ValueError(f"Plot series '{series}' has no plottable points in the selected window.")
25032 if is_iteration_history and len(selected_steps) == 1:
25033 grouped = {
25034 label.rsplit(" · step ", 1)[0]: points
25035 for label, points in grouped.items()
25036 }
25037 all_values = [point[1] for points in grouped.values() for point in points]
25038 use_log = not linear_y and field in _SUMMARY_PLOT_LOG_SCALE_FIELDS and all(value > 0 for value in all_values)
25039 window_token = f"last-{last_n}" if last_n is not None else "full"
25040 safe_series = re.sub(r"[^A-Za-z0-9_.-]+", "_", series)
25041 fallback = os.path.join(
25042 context["run_dir"], CANONICAL_RUN_PATHS["plots"],
25043 f"{safe_series}_{window_token}.png",
25044 )
25045 field_label = _summary_field_label(field)
25046 source_title = _SUMMARY_PLOT_SOURCE_TITLES.get(source, _humanize_plot_identifier(source))
25047 title = f"{source_title}: {field_label}"
25048 if is_iteration_history:
25049 title += " convergence"
25050 subtitle_bits = []
25051 if selected_steps:
25052 ordered = sorted(selected_steps)
25053 subtitle_bits.append(
25054 f"Latest physical step {ordered[0]}" if len(ordered) == 1
25055 else f"Latest available physical steps {ordered[0]}–{ordered[-1]}"
25056 )
25057 if last_n is not None:
25058 subtitle_bits.append(f"Last {last_n} samples per curve")
25059 legend_title = {
25060 "continuity": "Block",
25061 "momentum": "Block",
25062 "poisson": "Block",
25063 "profiling": "Function",
25064 "spectra": "Spectrum task",
25065 }.get(source, "Series")
25066 only_label = next(iter(grouped)) if len(grouped) == 1 else None
25067 show_legend = len(grouped) > 1 or bool(
25068 only_label and (only_label.lower().startswith("block ") or source == "profiling")
25069 )
25070 return {
25071 "schema_version": 1,
25072 "plot_type": "iteration_history" if is_iteration_history else "time_history",
25073 "series": series,
25074 "title": title,
25075 "subtitle": " · ".join(subtitle_bits) or None,
25076 "x_label": x_label,
25077 "x_kind": x_kind,
25078 "y_label": field_label,
25079 "y_scale": "log" if use_log else "linear",
25080 "include_zero_y": not use_log and all(value >= 0.0 for value in all_values),
25081 "show_markers": not is_iteration_history,
25082 "show_legend": show_legend,
25083 "legend_title": legend_title,
25084 "window": {"mode": "last" if last_n is not None else "full", "last": last_n},
25085 "lines": [{"label": label, "points": points} for label, points in sorted(grouped.items())],
25086 "output_path": os.path.abspath(output_path) if output_path else None,
25087 "fallback_output_path": fallback,
25088 }
25089
25090
25091def _render_summary_plot_catalog(catalog: list, output_format: str):
25092 """!
25093 @brief Render available summarize plot-series metadata.
25094 @param[in] catalog Available series catalog.
25095 @param[in] output_format Text or JSON output format.
25096 """
25097 if output_format == "json":
25098 print(json.dumps({"available_series": catalog}, indent=2, sort_keys=True))
25099 return
25100 print("\nAVAILABLE PLOT SERIES")
25101 print("=" * 78)
25102 for item in catalog:
25103 labels = ", ".join(line["label"] for line in item["lines"])
25104 print(f" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
25105 print(f" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
25106
25107
25108# The run's CSV records `summarize --table` prints, by name: (directory key, file name).
25109# Per-block solver histories take a `:<block>` suffix (block 0 by default).
25110SUMMARY_TABLES = {
25111 "continuity": ("log_dir", "Continuity_Metrics.csv"),
25112 "convergence": ("log_dir", "solution_convergence.csv"),
25113 "memory": ("log_dir", RUNTIME_MEMORY_FILE),
25114 "profiling": ("log_dir", "ProfilingSummary_Solver.csv"),
25115 "profiling-post": ("log_dir", "ProfilingSummary_PostProcessor.csv"),
25116 "profiling-steps": ("log_dir", PROFILING_TIMESTEP_FILE),
25117 "picard": ("log_dir", "Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_{block}.csv"),
25118 "newton": ("log_dir", "Momentum_Solver_Newton_Krylov_Summary_Block_{block}.csv"),
25119 "newton-history": ("log_dir", "Momentum_Solver_Newton_Krylov_History_Block_{block}.csv"),
25120 "newton-linear": ("log_dir", "Momentum_Solver_Newton_Krylov_Linear_History_Block_{block}.csv"),
25121 "poisson": ("log_dir", "Poisson_Solver_Convergence_History_Block_{block}.csv"),
25122 "search": ("metrics_dir", "search_metrics.csv"),
25123 "scatter": ("metrics_dir", "scatter_metrics.csv"),
25124 "interpolation-error": ("metrics_dir", "interpolation_error.csv"),
25125 "les": ("metrics_dir", "les_coefficient.csv"),
25126 "wall-model": ("metrics_dir", "wall_model.csv"),
25127 "driven-flow": ("metrics_dir", "driven_flow.csv"),
25128 "particle-initial-fields": ("metrics_dir", "particle_initial_fields.csv"),
25129}
25130
25131
25132def _summary_table_path(context: dict, name: str) -> str:
25133 """!
25134 @brief Resolve a `summarize --table` name to the CSV it prints.
25135 @param[in] context Summary context returned by `_build_summary_context()`.
25136 @param[in] name Table name from SUMMARY_TABLES, optionally `:<block>`.
25137 @return Path to the CSV (it may not exist).
25138 @throws ValueError When the name is unknown or the block is not an integer.
25139 """
25140 base, _, block = name.partition(":")
25141 if base not in SUMMARY_TABLES:
25142 raise ValueError(f"Unknown table '{base}'. Known tables: {', '.join(sorted(SUMMARY_TABLES))}.")
25143 if block and not block.isdigit():
25144 raise ValueError(f"Table block '{block}' must be a non-negative integer.")
25145 directory_key, file_name = SUMMARY_TABLES[base]
25146 if "{block}" in file_name:
25147 file_name = file_name.format(block=int(block or 0))
25148 elif block:
25149 raise ValueError(f"Table '{base}' is not per-block; drop ':{block}'.")
25150 return os.path.join(context[directory_key], file_name)
25151
25152
25153def _list_summary_tables(context: dict, output_format: str):
25154 """!
25155 @brief Print the `summarize --table` names whose CSV this run has, with row counts.
25156 @param[in] context Summary context returned by `_build_summary_context()`.
25157 @param[in] output_format Text or JSON output format.
25158 """
25159 found = []
25160 for name in sorted(SUMMARY_TABLES):
25161 names = [name]
25162 if "{block}" in (SUMMARY_TABLES[name][1] or ""):
25163 pattern = os.path.join(context["log_dir"], SUMMARY_TABLES[name][1].format(block="*"))
25164 blocks = sorted(int(re.search(r"_Block_(\d+)\.csv$", path).group(1)) for path in glob.glob(pattern))
25165 names = [name if b == 0 else f"{name}:{b}" for b in blocks]
25166 for table in names:
25167 path = _summary_table_path(context, table)
25168 if os.path.isfile(path):
25170 columns = len(rows[0][1]) if rows else 0
25171 found.append({"table": table, "rows": len(rows), "columns": columns, "path": path})
25172 if output_format == "json":
25173 print(json.dumps({"available_tables": found}, indent=2, sort_keys=True))
25174 return
25175 if not found:
25176 raise ValueError("No CSV records were found in the run (a run older than the CSV records keeps text logs).")
25177 print("\nAVAILABLE TABLES")
25178 print("=" * 78)
25179 for item in found:
25180 print(f" {item['table']:<26} rows={item['rows']:<7} columns={item['columns']:<3} {os.path.relpath(item['path'])}")
25181
25182
25183def _render_summary_table(context: dict, name: str, last_n, columns, output_format: str):
25184 """!
25185 @brief Print the last rows of one of the run's CSV records as aligned columns (or JSON).
25186 @param[in] context Summary context returned by `_build_summary_context()`.
25187 @param[in] name Table name (`summarize --list-tables`).
25188 @param[in] last_n Rows to show from the end; None shows the last 20.
25189 @param[in] columns Comma-separated column names to keep, or None for all.
25190 @param[in] output_format Text or JSON output format.
25191 @throws ValueError When the table is unknown, absent, or a requested column is missing.
25192 """
25193 path = _summary_table_path(context, name)
25194 if not os.path.isfile(path):
25195 raise ValueError(f"Table '{name}' has no file at {os.path.relpath(path)}; --list-tables names the ones this run has.")
25197 if not rows:
25198 raise ValueError(f"Table '{name}' has no data rows yet.")
25199 header = list(rows[0][1].keys())
25200 if columns:
25201 wanted = [column.strip() for column in columns.split(",") if column.strip()]
25202 missing = [column for column in wanted if column not in header]
25203 if missing:
25204 raise ValueError(f"Table '{name}' has no column(s) {', '.join(missing)}; its columns are: {', '.join(header)}.")
25205 keep = ([header[0]] if header[0] not in wanted else []) + wanted
25206 else:
25207 keep = header
25208 shown = rows[-(last_n or 20):]
25209 if output_format == "json":
25210 print(json.dumps({"table": name, "path": path, "columns": keep,
25211 "rows": [{"segment": segment, **{key: row.get(key) for key in keep}} for segment, row in shown]},
25212 indent=2))
25213 return
25214 widths = {key: max(len(key), *(len(row.get(key) or "") for _, row in shown)) for key in keep}
25215 print(f"\n{name}: {os.path.relpath(path)} (last {len(shown)} of {len(rows)} rows)")
25216 print(" ".join(key.rjust(widths[key]) for key in keep))
25217 print(" ".join("-" * widths[key] for key in keep))
25218 previous_segment = shown[0][0]
25219 for segment, row in shown:
25220 if segment != previous_segment:
25221 print(f"# continuation segment {segment}")
25222 previous_segment = segment
25223 print(" ".join((row.get(key) or "").rjust(widths[key]) for key in keep))
25224
25225
25226def _invoke_plot_gen(request: dict):
25227 """!
25228 @brief Invoke standalone plot.gen with one normalized request over stdin.
25229 @param[in] request Versioned normalized plot request.
25230 """
25231 plotgen_path = os.path.join(GENERATORS_PATH, "plot.gen")
25232 if not os.path.isfile(plotgen_path):
25233 raise ValueError(f"plot.gen script not found: {plotgen_path}")
25234 result = subprocess.run(
25235 [sys.executable, plotgen_path, "--input", "-"],
25236 input=json.dumps(request),
25237 text=True,
25238 capture_output=True,
25239 check=False,
25240 )
25241 if result.stdout:
25242 print(result.stdout.rstrip())
25243 if result.returncode != 0:
25244 details = (result.stderr or result.stdout or "unknown plotting error").strip()
25245 if result.returncode == 3:
25246 raise PlotDependencyError(details)
25247 raise ValueError(f"plot.gen failed with exit code {result.returncode}: {details}")
25248
25249
25251 """!
25252 @brief Build and render a read-only health summary for a run step.
25253 @param[in] args Command-line style argument list supplied to the function.
25254 """
25255 if args.step is not None and args.step < 0:
25256 fail_cli_usage("--step must be a non-negative integer.")
25257 if args.snapshot_rows < 1:
25258 fail_cli_usage("--snapshot-rows must be at least 1.")
25259 plot_series = getattr(args, "plot_series", None)
25260 list_plot_series = bool(getattr(args, "list_plot_series", False))
25261 plot_spectrum = getattr(args, "plot_spectrum", None)
25262 last_n = getattr(args, "last_n", None)
25263 plot_output = getattr(args, "plot_output", None)
25264 linear_y = bool(getattr(args, "linear_y", False))
25265 table = getattr(args, "table", None)
25266 list_tables = bool(getattr(args, "list_tables", False))
25267 table_columns = getattr(args, "table_columns", None)
25268 plot_mode = bool(plot_series or list_plot_series or plot_spectrum is not None or table or list_tables)
25269 existing_selectors = any(
25270 [
25271 getattr(args, "overview", False),
25272 getattr(args, "case", False),
25273 getattr(args, "solver", False),
25274 getattr(args, "monitor", False),
25275 args.step is not None,
25276 getattr(args, "latest", False),
25277 getattr(args, "max_step", False),
25278 ]
25279 )
25280 if plot_mode and existing_selectors:
25281 fail_cli_usage("Plot discovery and --plot cannot be combined with config or selected-step selectors.")
25282 if not plot_series and not table and last_n is not None:
25283 fail_cli_usage("--last requires --plot or --table.")
25284 if table_columns and not table:
25285 fail_cli_usage("--columns requires --table.")
25286 if not plot_series and plot_spectrum is None and (plot_output or linear_y):
25287 fail_cli_usage("--plot-output and --linear-y require --plot or --plot-spectrum.")
25288 if last_n is not None and last_n < 1:
25289 fail_cli_usage("--last must be a positive integer.")
25290 if plot_series and args.output_format == "json":
25291 fail_cli_usage("--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
25292 if table or list_tables:
25293 context = _build_summary_context(args.run_dir)
25294 try:
25295 if list_tables:
25296 _list_summary_tables(context, args.output_format)
25297 else:
25298 _render_summary_table(context, table, last_n, table_columns, args.output_format)
25299 except ValueError as exc:
25301 ERROR_CODE_CFG_INVALID_VALUE,
25302 key="table",
25303 file_path=context["log_dir"],
25304 message=str(exc),
25305 hint="--list-tables names the CSV records this run has.",
25306 )
25307 sys.exit(1)
25308 return
25309 if plot_mode:
25310 context = _build_summary_context(args.run_dir)
25311 try:
25312 records = _collect_summary_plot_records(context)
25313 catalog = _build_summary_plot_catalog(records)
25314 if list_plot_series:
25315 if not catalog:
25316 raise ValueError("No plottable scalar histories were found in the run logs.")
25317 _render_summary_plot_catalog(catalog, args.output_format)
25318 return
25319 if plot_spectrum is not None:
25321 context, plot_spectrum, True, linear_y, plot_output
25322 )
25323 else:
25325 context, records, plot_series, last_n, linear_y, plot_output
25326 )
25327 _invoke_plot_gen(request)
25328 return
25329 except PlotDependencyError as exc:
25331 ERROR_CODE_DEPENDENCY_MISSING,
25332 key="plotting",
25333 file_path=sys.executable,
25334 message=str(exc),
25335 )
25336 sys.exit(1)
25337 except ValueError as exc:
25339 ERROR_CODE_CFG_INVALID_VALUE,
25340 key="plot",
25341 file_path=context["log_dir"],
25342 message=str(exc),
25343 hint=("--list-plot-series names the scalar histories this run recorded."
25344 if plot_spectrum is None else
25345 "Spectra come from the spectra tasks of a post.yml (see the post-processing reference)."),
25346 )
25347 sys.exit(1)
25348
25349 selected_configs = {
25350 name
25351 for name in ("case", "solver", "monitor")
25352 if bool(getattr(args, name, False))
25353 }
25354 if getattr(args, "overview", False):
25355 selected_configs.update({"case", "solver", "monitor"})
25356 explicit_health = args.step is not None or bool(getattr(args, "latest", False)) or bool(getattr(args, "max_step", False))
25357 health_requested = explicit_health or (not selected_configs and not getattr(args, "overview", False))
25358
25359 context = None
25360 combined = {"storage": storage_state_summary(args.run_dir)}
25361 if selected_configs or getattr(args, "overview", False):
25362 context = _build_summary_context(args.run_dir)
25363 if getattr(args, "overview", False):
25364 combined["run_overview"] = _build_run_overview(context)
25365 combined["configuration"] = {}
25366 builders = {
25367 "case": _build_case_overview,
25368 "solver": _build_solver_overview,
25369 "monitor": _build_monitor_overview,
25370 }
25371 for name in ("case", "solver", "monitor"):
25372 if name in selected_configs:
25373 try:
25374 combined["configuration"][name] = builders[name](context)
25375 except (KeyError, TypeError, ValueError, ZeroDivisionError) as exc:
25377 ERROR_CODE_CFG_INVALID_VALUE,
25378 key=name,
25379 file_path=context["config_paths"][name],
25380 message=f"Could not summarize copied {name}.yml: {exc}",
25381 )
25382 sys.exit(1)
25383
25384 if not health_requested:
25385 combined["_health_requested"] = False
25386 render_selected_summary(combined, output_format=args.output_format)
25387 return
25388
25389 requested_step = args.step
25390 if requested_step is None and getattr(args, "latest", False):
25391 requested_step = None
25392 selection_mode = "max_step" if getattr(args, "max_step", False) else "latest"
25393 health_payload = build_run_summary_payload(
25394 args.run_dir,
25395 step=requested_step,
25396 snapshot_rows=args.snapshot_rows,
25397 selection_mode=selection_mode,
25398 )
25399 if set(combined) == {"storage"}:
25400 combined = {**health_payload, **combined, "_health_requested": True}
25401 render_selected_summary(combined, output_format=args.output_format)
25402 return
25403 combined = {**health_payload, **combined, "_health_requested": True}
25404 render_selected_summary(combined, output_format=args.output_format)
25405
25406
25407def _resolve_submission_target(run_dir: str = None, study_dir: str = None) -> dict:
25408 """!
25409 @brief Resolve a run/study submission target from explicit directory flags.
25410 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
25411 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
25412 @return Value returned by `_resolve_submission_target()`.
25413 """
25414 has_run_dir = bool(run_dir)
25415 has_study_dir = bool(study_dir)
25416 if has_run_dir == has_study_dir:
25417 fail_cli_usage("submit requires exactly one of --run-dir or --study-dir.")
25418
25419 target_kind = "run" if has_run_dir else "study"
25420 target_key = "run_dir" if target_kind == "run" else "study_dir"
25421 root_dir = os.path.abspath(run_dir if has_run_dir else study_dir)
25422 if not os.path.isdir(root_dir):
25424 ERROR_CODE_CFG_FILE_NOT_FOUND,
25425 key=target_key,
25426 file_path=root_dir,
25427 message=f"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
25428 )
25429 sys.exit(1)
25430
25431 scheduler_dir = os.path.join(root_dir, "scheduler")
25432 submission_path = os.path.join(scheduler_dir, "submission.json")
25433 continuation = False
25434 if target_kind == "study":
25435 # `sweep --continue` stages its scripts in a set of their own; the set staged
25436 # last is the one to submit.
25437 continue_path = os.path.join(scheduler_dir, "submission_continue.json")
25438 if os.path.isfile(continue_path) and (
25439 not os.path.isfile(submission_path)
25440 or os.path.getmtime(continue_path) >= os.path.getmtime(submission_path)):
25441 submission_path, continuation = continue_path, True
25442 submission_meta = _read_json_if_exists(submission_path)
25443 if not isinstance(submission_meta, dict):
25445 ERROR_CODE_CFG_FILE_NOT_FOUND,
25446 key="scheduler.submission",
25447 file_path=submission_path,
25448 message="Target directory does not contain scheduler submission metadata.",
25449 hint="Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
25450 )
25451 sys.exit(1)
25452
25453 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
25454 if launch_mode == "local" and target_kind != "run":
25456 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25457 key="scheduler.launch_mode",
25458 file_path=submission_path,
25459 message="Local staged submission is supported for run directories only.",
25460 hint="Use --run-dir for local staged execution; study submit remains Slurm-only.",
25461 )
25462 sys.exit(1)
25463 if launch_mode not in {"slurm", "local"}:
25465 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25466 key="scheduler.launch_mode",
25467 file_path=submission_path,
25468 message=f"Target launch_mode={launch_mode or 'unknown'} is not supported.",
25469 hint="Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
25470 )
25471 sys.exit(1)
25472
25473 if target_kind == "run":
25474 script_map = {
25475 "solve": os.path.join(scheduler_dir, "solver.sbatch"),
25476 "post-process": os.path.join(scheduler_dir, "post.sbatch"),
25477 }
25478 display_label = "Run directory"
25479 manifest_path = None
25480 else:
25481 stage_keys = ({"solve": "solver_continue_array", "post-process": "post_continue_array",
25482 "metrics": "metrics_aggregate"} if continuation else
25483 {"solve": "solver_array", "post-process": "post_array",
25484 "metrics": "metrics_aggregate"})
25485 default_scripts = ({"solve": "solver_continue_array.sbatch", "post-process": "post_continue_array.sbatch",
25486 "metrics": "metrics_continue_aggregate.sbatch"} if continuation else
25487 {"solve": "solver_array.sbatch", "post-process": "post_array.sbatch",
25488 "metrics": "metrics_aggregate.sbatch"})
25489 script_map = {
25490 stage: ((submission_meta.get(key) or {}).get("script")
25491 or os.path.join(scheduler_dir, default_scripts[stage]))
25492 for stage, key in stage_keys.items()
25493 }
25494 display_label = "Study continuation" if continuation else "Study directory"
25495 manifest_path = os.path.join(root_dir, "study_manifest.json")
25496
25497 return {
25498 "target_kind": target_kind,
25499 "target_key": target_key,
25500 "root_dir": root_dir,
25501 "scheduler_dir": scheduler_dir,
25502 "submission_path": submission_path,
25503 "submission_meta": submission_meta,
25504 "launch_mode": launch_mode,
25505 "script_map": script_map,
25506 "display_label": display_label,
25507 "manifest_path": manifest_path,
25508 "continuation": continuation,
25509 "stage_keys": stage_keys if target_kind == "study" else None,
25510 }
25511
25512
25513def _get_submission_stage_metadata(target_context: dict, stage_name: str) -> dict:
25514 """!
25515 @brief Return stored metadata for one staged submission target.
25516 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
25517 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
25518 @return Value returned by `_get_submission_stage_metadata()`.
25519 """
25520 submission_meta = target_context["submission_meta"]
25521 if target_context["target_kind"] == "run":
25522 stages = submission_meta.get("stages", {})
25523 if not isinstance(stages, dict):
25524 return {}
25525 stage_meta = stages.get(stage_name)
25526 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
25527
25528 stage_meta = submission_meta.get(target_context["stage_keys"][stage_name])
25529 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
25530
25531
25532def _get_recorded_submission_stages(target_context: dict) -> list:
25533 """!
25534 @brief Return stage names explicitly recorded in scheduler submission metadata.
25535 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
25536 @return Value returned by `_get_recorded_submission_stages()`.
25537 """
25538 submission_meta = target_context["submission_meta"]
25539 recorded = []
25540 if target_context["target_kind"] == "run":
25541 stages = submission_meta.get("stages", {})
25542 if isinstance(stages, dict):
25543 for stage_name in ["solve", "post-process"]:
25544 if isinstance(stages.get(stage_name), dict):
25545 recorded.append(stage_name)
25546 return recorded
25547
25548 for stage_name in ("solve", "post-process"):
25549 if isinstance(submission_meta.get(target_context["stage_keys"][stage_name]), dict):
25550 recorded.append(stage_name)
25551 return recorded
25552
25553
25554def _build_submit_missing_stage_hint(target_context: dict, requested_stage: str, selected_stages: list) -> str:
25555 """!
25556 @brief Build an actionable hint for requested submit stages missing from metadata.
25557 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
25558 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
25559 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
25560 @return Value returned by `_build_submit_missing_stage_hint()`.
25561 """
25562 recorded_stages = _get_recorded_submission_stages(target_context)
25563 recorded_set = set(recorded_stages)
25564 selected_set = set(selected_stages)
25565 solve_stage_command = (
25566 "picurv run --solve ... --no-submit"
25567 if target_context["target_kind"] == "run"
25568 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25569 )
25570 post_stage_command = (
25571 "picurv run --post-process --post <post.yml> ... --no-submit"
25572 if target_context["target_kind"] == "run"
25573 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25574 )
25575 solve_post_command = (
25576 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
25577 if target_context["target_kind"] == "run"
25578 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25579 )
25580
25581 if requested_stage == "all" and not recorded_set:
25582 return (
25583 "--stage all submits every staged stage, but submission metadata records none. "
25584 f"Re-stage with `{solve_stage_command}` or `{post_stage_command}`."
25585 )
25586
25587 if selected_set == {"solve"} and "solve" not in recorded_set:
25588 return (
25589 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
25590 f"Re-stage with `{solve_stage_command}`."
25591 )
25592 if selected_set == {"post-process"} and "post-process" not in recorded_set:
25593 return (
25594 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
25595 f"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
25596 )
25597
25598 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
25599
25600
25601def _set_submission_stage_metadata(target_context: dict, stage_name: str, stage_meta: dict):
25602 """!
25603 @brief Persist one stage's metadata back into the submission payload.
25604 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
25605 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
25606 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
25607 """
25608 submission_meta = target_context["submission_meta"]
25609 if target_context["target_kind"] == "run":
25610 stages = submission_meta.get("stages")
25611 if not isinstance(stages, dict):
25612 stages = {}
25613 submission_meta["stages"] = stages
25614 stages[stage_name] = stage_meta
25615 return
25616
25617 submission_meta[target_context["stage_keys"][stage_name]] = stage_meta
25618
25619
25620def _write_submission_target_metadata(target_context: dict):
25621 """!
25622 @brief Write updated submission metadata back to disk.
25623 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
25624 """
25625 write_json_file(target_context["submission_path"], target_context["submission_meta"])
25626
25627 manifest_path = target_context.get("manifest_path")
25628 if manifest_path and os.path.isfile(manifest_path):
25629 manifest_payload = _read_json_if_exists(manifest_path)
25630 if isinstance(manifest_payload, dict):
25631 if target_context.get("continuation"):
25632 manifest_payload.setdefault("continuation", {})["submission"] = target_context["submission_meta"]
25633 else:
25634 manifest_payload["submission"] = target_context["submission_meta"]
25635 write_json_file(manifest_path, manifest_payload)
25636
25637
25638def staged_control_directories(control_path: str) -> tuple:
25639 """!
25640 @brief Read run-owned directory values from a staged control file.
25641
25642 @details Tokenizes with `shlex` because PETSc's options-file parser treats a
25643 double-quoted span as a single token. Splitting on whitespace would read
25644 `-log_dir "/tmp/VICTIM DIR"` as the value `"/tmp/VICTIM`, which looks
25645 relative and contained while PETSc would use the absolute path.
25646
25647 Malformed quoting is reported rather than skipped: a line the parser
25648 cannot interpret is exactly the case where preflight must not assume the
25649 run is safe.
25650 @param[in] control_path Path to a generated `.control` file.
25651 @return Tuple of (values, parse_errors).
25652 """
25653 flag_to_key = {
25654 "-log_dir": "log",
25655 "-output_dir": "output",
25656 "-restart_dir": "restart",
25657 "-analysis_dir": "analysis",
25658 }
25659 values: dict = {}
25660 parse_errors: list = []
25661 try:
25662 lines = read_text_file_lines(control_path)
25663 except OSError as exc:
25664 return values, [f"could not be read ({exc})"]
25665 for number, line in enumerate(lines, start=1):
25666 if not line.strip():
25667 continue
25668 try:
25669 tokens = shlex.split(line, comments=True)
25670 except ValueError as exc:
25671 parse_errors.append(
25672 f"line {number} has malformed quoting and cannot be interpreted ({exc}); "
25673 f"refusing to assume it is safe"
25674 )
25675 continue
25676 if tokens and tokens[0] in RESERVED_INDIRECTION_FLAGS:
25677 parse_errors.append(
25678 f"line {number} uses '{tokens[0]}', which PETSc expands itself; its contents "
25679 f"cannot be checked here and could set a run directory. Re-stage without it"
25680 )
25681 continue
25682 if len(tokens) >= 2 and tokens[0] in flag_to_key:
25683 values[flag_to_key[tokens[0]]] = tokens[1]
25684 return values, parse_errors
25685
25686
25687def preflight_config_directories(root_dir: str) -> list:
25688 """!
25689 @brief Every config directory under a run or study root that may hold a control file.
25690
25691 @details A study keeps its controls under `cases/<member>/config`, not at the study
25692 root, so a preflight that only looked at `<root>/config` was empty for every
25693 study. This walks the tree so members and nested runs are covered.
25694 @param[in] root_dir Run or study directory.
25695 @return Sorted config directory paths.
25696 """
25697 found: set = set()
25698 root = os.path.abspath(root_dir)
25699 direct = os.path.join(root, "config")
25700 if os.path.isdir(direct):
25701 found.add(direct)
25702 for current, dirnames, _ in os.walk(root):
25703 dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(current, d))]
25704 if os.path.basename(current) == "config" and glob.glob(os.path.join(current, "*.control")):
25705 found.add(current)
25706 return sorted(found)
25707
25708
25709def preflight_staged_run_directories(root_dir: str) -> tuple:
25710 """!
25711 @brief Re-check run-directory safety against an already-staged run or study.
25712
25713 @details Staging validates the configuration it is given, but a staged run can be
25714 edited, or produced by an older version, before submission. This re-reads the
25715 effective staged control files - across study members and nested runs - and
25716 applies the same rules as configuration validation, plus a physical
25717 containment check that a symlink cannot slip past.
25718 @param[in] root_dir Run or study directory being submitted.
25719 @return Tuple of (errors, warnings).
25720 """
25721 errors: list = []
25722 warnings: list = []
25723 config_dirs = preflight_config_directories(root_dir)
25724 if not config_dirs:
25725 return errors, warnings
25726 canonical = {
25727 "log": CANONICAL_RUN_PATHS["logs"],
25728 "output": CANONICAL_RUN_PATHS["output"],
25729 "restart": CANONICAL_RUN_PATHS["restart"],
25730 "analysis": CANONICAL_RUN_PATHS["metrics"],
25731 }
25732 for config_dir in config_dirs:
25733 run_root = os.path.dirname(config_dir)
25734 for control in sorted(glob.glob(os.path.join(config_dir, "*.control"))):
25735 staged, parse_errors = staged_control_directories(control)
25736 label = os.path.relpath(control)
25737 errors.extend(f" {label}: {message}" for message in parse_errors)
25738 if not staged:
25739 continue
25740 for key, expected in canonical.items():
25741 actual = staged.get(key)
25742 if actual is not None and normalized_run_directory(actual) != expected:
25743 errors.append(
25744 f" {label}: -{key}_dir is {actual!r}; the canonical value is "
25745 f"{expected!r}. Re-stage the run instead of editing its path flags."
25746 )
25747 effective = effective_run_directories(staged)
25748 control_errors, control_warnings = evaluate_run_directories(
25749 effective, False, explicit=set(staged)
25750 )
25751 errors.extend(f" {label}: {message}" for message in control_errors)
25752 warnings.extend(f" {label}: {message}" for message in control_warnings)
25753 for _, verdict, message in classify_physical_containment(run_root, effective):
25754 errors.append(f" {label}: {message}")
25755 return errors, warnings
25756
25757
25758def read_text_file_lines(path: str) -> list:
25759 """!
25760 @brief Read a text file into a list of lines.
25761 @param[in] path File to read.
25762 @return List of lines.
25763 """
25764 with open(path, "r", encoding="utf-8", errors="replace") as handle:
25765 return handle.readlines()
25766
25767
25769 """!
25770 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
25771 @param[in] args Command-line style argument list supplied to the function.
25772 """
25773 if getattr(args, "run_dir", None) and os.path.isdir(args.run_dir):
25774 adopt_relocated_artifact(args.run_dir)
25775 elif getattr(args, "study_dir", None) and os.path.isdir(args.study_dir):
25776 adopt_relocated_artifact(args.study_dir, kind="study")
25777 target_context = _resolve_submission_target(
25778 run_dir=getattr(args, "run_dir", None),
25779 study_dir=getattr(args, "study_dir", None),
25780 )
25781 try:
25782 require_storage_payload_local(target_context["root_dir"], "submission")
25783 except StorageError as exc:
25784 print(f"[FATAL] {exc}", file=sys.stderr)
25785 sys.exit(1)
25786 preflight_errors, preflight_warnings = preflight_staged_run_directories(
25787 target_context["root_dir"]
25788 )
25789 for warning in preflight_warnings:
25790 print(f"[WARNING] {warning.strip()}", file=sys.stderr)
25791 if preflight_errors:
25792 print(
25793 "[FATAL] Submission preflight failed: the staged run has an unsafe run-directory "
25794 "configuration.",
25795 file=sys.stderr,
25796 )
25797 for violation in preflight_errors:
25798 print(violation, file=sys.stderr)
25799 print(
25800 " Re-stage the run so PICurv regenerates its canonical path flags.",
25801 file=sys.stderr,
25802 )
25803 sys.exit(1)
25804 if target_context["target_kind"] == "study":
25805 cold_cases = cold_study_members(target_context["root_dir"])
25806 if cold_cases:
25807 print(
25808 "[FATAL] Submission requires cold-storage study member(s): " + ", ".join(cold_cases),
25809 file=sys.stderr,
25810 )
25811 sys.exit(1)
25812 stage_order = ["solve", "post-process"]
25813 requested_stage = args.stage
25814 # `all` is every staged stage not yet submitted; when each was, every staged stage, so the
25815 # refusal names them; with none staged, both, so the error says how to stage one.
25816 recorded_stages = _get_recorded_submission_stages(target_context)
25817 pending_stages = [stage for stage in recorded_stages
25818 if not _get_submission_stage_metadata(target_context, stage).get("submitted")]
25819 selected_stages = ([requested_stage] if requested_stage != "all"
25820 else pending_stages or recorded_stages or stage_order)
25821
25822 print(f"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
25823 print(f"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
25824 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
25825
25826 if target_context.get("launch_mode") == "local":
25827 submit_staged_local_run(args, target_context, selected_stages)
25828 return
25829
25830 stage_plans = []
25831 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
25832 solve_existing_job_id = str(solve_existing_meta.get("job_id", "")).strip()
25833
25834 for stage_name in selected_stages:
25835 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
25836 script_path = target_context["script_map"][stage_name]
25837 missing_stage_hint = _build_submit_missing_stage_hint(target_context, requested_stage, selected_stages)
25838 if not existing_meta:
25840 ERROR_CODE_CFG_MISSING_KEY,
25841 key=f"scheduler.{stage_name}.metadata",
25842 file_path=target_context["submission_path"],
25843 message=f"Submission metadata does not record stage '{stage_name}'.",
25844 hint=missing_stage_hint,
25845 )
25846 sys.exit(1)
25847
25848 if not os.path.isfile(script_path):
25850 ERROR_CODE_CFG_FILE_NOT_FOUND,
25851 key=f"scheduler.{stage_name}.script",
25852 file_path=script_path,
25853 message=f"Required {stage_name} sbatch artifact is missing.",
25854 hint=missing_stage_hint,
25855 )
25856 sys.exit(1)
25857
25858 if existing_meta.get("submitted") and not args.force:
25860 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25861 key=f"scheduler.{stage_name}.submitted",
25862 file_path=target_context["submission_path"],
25863 message=f"Stage '{stage_name}' is already recorded as submitted.",
25864 hint="Use --force to resubmit this stage intentionally.",
25865 )
25866 sys.exit(1)
25867
25868 dependency = None
25869 if stage_name == "post-process":
25870 if "solve" in selected_stages:
25871 dependency = "__NEW_SOLVE_JOB_ID__"
25872 elif solve_existing_meta.get("submitted") and solve_existing_job_id:
25873 dependency = solve_existing_job_id
25874 end, state = slurm_job_end_state(solve_existing_job_id)
25875 if end == "completed":
25876 # Slurm rejects a dependency on a job it has forgotten, and the post
25877 # job plans from committed checkpoints when it starts, so a solve that
25878 # already succeeded leaves nothing to wait for.
25879 print(f"[INFO] Solve job {solve_existing_job_id} has completed; the post job is "
25880 "submitted without a dependency.")
25881 dependency = None
25882 elif end == "failed" and not args.force:
25884 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25885 key="scheduler.post-process.dependency",
25886 file_path=target_context["submission_path"],
25887 message=f"Solve job {solve_existing_job_id} ended {state}, so a post job waiting on its success would never start.",
25888 hint="Resubmit the solve, or use --force to post-process the checkpoints it committed.",
25889 )
25890 sys.exit(1)
25891 elif end != "active" and end != "unchecked":
25892 # Failed with --force, or ended in a state sacct cannot report.
25893 print(f"[WARNING] Solve job {solve_existing_job_id} is no longer queued "
25894 f"({state or 'end state unavailable from sacct'}); the post job is submitted "
25895 "without a dependency and processes what was committed.", file=sys.stderr)
25896 dependency = None
25897 elif solve_existing_meta and not args.force:
25898 # A post staged on its own has no solve to wait for: it reads a run solved
25899 # earlier and plans its steps from what is committed when it starts.
25901 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25902 key="scheduler.post-process.dependency",
25903 file_path=target_context["submission_path"],
25904 message="This run's staged solve has not been submitted, so its post-process job would have nothing to read.",
25905 hint="Submit --stage solve or --stage all first, or use --force to submit the post job without a dependency.",
25906 )
25907 sys.exit(1)
25908
25909 stage_plans.append(
25910 {
25911 "stage": stage_name,
25912 "script": script_path,
25913 "dependency": dependency,
25914 "existing_meta": existing_meta,
25915 }
25916 )
25917
25918 # A study aggregates its metrics after its post array, as `sweep` chains it, whether
25919 # or not every member succeeded.
25920 if target_context["target_kind"] == "study" and "post-process" in selected_stages:
25921 metrics_meta = _get_submission_stage_metadata(target_context, "metrics")
25922 metrics_script = target_context["script_map"]["metrics"]
25923 if metrics_meta and os.path.isfile(metrics_script) and (not metrics_meta.get("submitted") or args.force):
25924 stage_plans.append({
25925 "stage": "metrics",
25926 "script": metrics_script,
25927 "dependency": "__NEW_POST_JOB_ID__",
25928 "dependency_type": "afterany",
25929 "existing_meta": metrics_meta,
25930 })
25931
25932 if args.dry_run:
25933 for plan in stage_plans:
25934 cmd = ["sbatch"]
25935 dependency = plan["dependency"]
25936 dependency_type = plan.get("dependency_type", "afterok")
25937 if dependency == "__NEW_SOLVE_JOB_ID__":
25938 cmd.append("--dependency=afterok:<new solve job id>")
25939 elif dependency == "__NEW_POST_JOB_ID__":
25940 cmd.append(f"--dependency={dependency_type}:<new post job id>")
25941 elif dependency:
25942 cmd.append(f"--dependency={dependency_type}:{dependency}")
25943 cmd.append(plan["script"])
25944 print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
25945 print("[INFO] Dry-run only. No jobs were submitted.")
25946 return
25947
25948 new_job_ids = {}
25949 for plan in stage_plans:
25950 dependency = plan["dependency"]
25951 dependency_type = plan.get("dependency_type", "afterok")
25952 if dependency == "__NEW_SOLVE_JOB_ID__":
25953 dependency = new_job_ids.get("solve")
25954 elif dependency == "__NEW_POST_JOB_ID__":
25955 dependency = new_job_ids.get("post-process")
25956
25957 submit_info = submit_sbatch(plan["script"], dependency=dependency, dependency_type=dependency_type)
25958 stage_meta = copy.deepcopy(plan["existing_meta"])
25959 stage_meta.update(submit_info)
25960 stage_meta["script"] = plan["script"]
25961 stage_meta["submitted"] = True
25962 if dependency:
25963 stage_meta["dependency"] = f"{dependency_type}:{dependency}"
25964 else:
25965 stage_meta.pop("dependency", None)
25966
25967 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
25968 print(f"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
25969 new_job_ids[plan["stage"]] = submit_info["job_id"]
25970
25971 _write_submission_target_metadata(target_context)
25972
25973
25974def submit_staged_local_run(args, target_context: dict, selected_stages: list):
25975 """!
25976 @brief Execute previously staged local run commands from scheduler/submission.json.
25977 @param[in] args Command-line style argument list supplied to the function.
25978 @param[in] target_context Resolved submission target context.
25979 @param[in] selected_stages Ordered stage names selected by the user.
25980 """
25981 if target_context["target_kind"] != "run":
25983 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25984 key="scheduler.launch_mode",
25985 file_path=target_context["submission_path"],
25986 message="Local staged execution is supported for run directories only.",
25987 hint="Use --run-dir for local staged execution.",
25988 )
25989 sys.exit(1)
25990
25991 stage_plans = []
25992 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
25993 # Only a solve staged in this same set can still be pending; a post staged on its own
25994 # reads a run solved earlier and plans its steps from what is committed when it starts.
25995 solve_pending = bool(solve_existing_meta) and not bool(
25996 solve_existing_meta.get("submitted") or solve_existing_meta.get("executed"))
25997
25998 for stage_name in selected_stages:
25999 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
26000 command = existing_meta.get("command")
26001 if not isinstance(command, list) or not command:
26002 if existing_meta:
26003 hint = "Re-stage the run with picurv run --no-submit before calling picurv submit."
26004 else:
26005 hint = _build_submit_missing_stage_hint(target_context, args.stage, selected_stages)
26007 ERROR_CODE_CFG_MISSING_KEY,
26008 key=f"scheduler.{stage_name}.command",
26009 file_path=target_context["submission_path"],
26010 message=f"Required local command metadata for stage '{stage_name}' is missing.",
26011 hint=hint,
26012 )
26013 sys.exit(1)
26014
26015 if existing_meta.get("submitted") and not args.force:
26017 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26018 key=f"scheduler.{stage_name}.submitted",
26019 file_path=target_context["submission_path"],
26020 message=f"Stage '{stage_name}' is already recorded as submitted.",
26021 hint="Use --force to execute this stage again intentionally.",
26022 )
26023 sys.exit(1)
26024
26025 if stage_name == "post-process" and "solve" not in selected_stages and not args.force and solve_pending:
26027 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26028 key="scheduler.post-process.dependency",
26029 file_path=target_context["submission_path"],
26030 message="This run's staged solve has not run yet, so its post-process stage has nothing to read.",
26031 hint="Submit --stage solve or --stage all first, or use --force to run the post stage anyway.",
26032 )
26033 sys.exit(1)
26034
26035 log_file = existing_meta.get("log_file")
26036 if not isinstance(log_file, str) or not log_file.strip():
26037 log_file = os.path.join("scheduler", f"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
26038
26039 stage_plans.append(
26040 {
26041 "stage": stage_name,
26042 "command": [str(token) for token in command],
26043 "log_file": log_file,
26044 "existing_meta": existing_meta,
26045 }
26046 )
26047
26048 if args.dry_run:
26049 for plan in stage_plans:
26050 print(f"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
26051 print(f"[DRY-RUN] Log file : {plan['log_file']}")
26052 print("[INFO] Dry-run only. No local commands were executed.")
26053 return
26054
26055 monitor_cfg = None
26056 monitor_path = os.path.join(target_context["root_dir"], "config", "monitor.yml")
26057 if os.path.isfile(monitor_path):
26058 monitor_cfg = read_yaml_file(monitor_path)
26059
26060 for plan in stage_plans:
26061 if plan["stage"] == "solve":
26062 try:
26063 with runtime_stage_lock(target_context["root_dir"], "solver"):
26064 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
26065 except StorageError as exc:
26066 print(f"[FATAL] {exc}", file=sys.stderr)
26067 sys.exit(1)
26068 else:
26069 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
26070 stage_meta = copy.deepcopy(plan["existing_meta"])
26071 stage_meta["command"] = plan["command"]
26072 stage_meta["command_string"] = format_command_for_display(plan["command"])
26073 stage_meta["log_file"] = plan["log_file"]
26074 stage_meta["submitted"] = True
26075 stage_meta["executed"] = True
26076 stage_meta["completed_at"] = datetime.now().isoformat()
26077 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
26078 print(f"[SUCCESS] Executed local {plan['stage']} stage.")
26079
26080 _write_submission_target_metadata(target_context)
26081
26082
26084 """!
26085 @brief Cancel Slurm-submitted jobs for an existing run directory.
26086 @param[in] args Command-line style argument list supplied to the function.
26087 """
26088 run_dir = os.path.abspath(args.run_dir)
26089 if not os.path.isdir(run_dir):
26091 ERROR_CODE_CFG_FILE_NOT_FOUND,
26092 key="run_dir",
26093 file_path=run_dir,
26094 message="Run directory not found.",
26095 )
26096 sys.exit(1)
26097
26098 submission_path = os.path.join(run_dir, "scheduler", "submission.json")
26099 submission_meta = _read_json_if_exists(submission_path)
26100 if not isinstance(submission_meta, dict):
26102 ERROR_CODE_CFG_FILE_NOT_FOUND,
26103 key="scheduler.submission",
26104 file_path=submission_path,
26105 message="Run directory does not contain scheduler submission metadata.",
26106 hint="Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
26107 )
26108 sys.exit(1)
26109
26110 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
26111 if launch_mode != "slurm":
26113 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26114 key="scheduler.launch_mode",
26115 file_path=submission_path,
26116 message=f"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
26117 hint="picurv cancel currently supports Slurm-submitted runs only.",
26118 )
26119 sys.exit(1)
26120
26121 stage_order = ["solve", "post-process"]
26122 requested_stage = args.stage
26123 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
26124 recorded_stages = submission_meta.get("stages", {})
26125 if not isinstance(recorded_stages, dict):
26126 recorded_stages = {}
26127
26128 job_to_stages = {}
26129 skipped = []
26130 for stage_name in selected_stages:
26131 stage_meta = recorded_stages.get(stage_name)
26132 if not isinstance(stage_meta, dict):
26133 skipped.append((stage_name, "no stage metadata recorded"))
26134 continue
26135
26136 job_id = str(stage_meta.get("job_id", "")).strip()
26137 if not stage_meta.get("submitted"):
26138 skipped.append((stage_name, "job was generated but not submitted"))
26139 continue
26140 if not job_id:
26141 skipped.append((stage_name, "submitted stage is missing a recorded job id"))
26142 continue
26143
26144 job_to_stages.setdefault(job_id, []).append(stage_name)
26145
26146 if not job_to_stages:
26147 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
26148 print(f"[INFO] Submission metadata : {os.path.relpath(submission_path)}")
26149 for stage_name, reason in skipped:
26150 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
26151 print("[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
26152 sys.exit(1)
26153
26154 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
26155 print(f"[INFO] Submission metadata : {os.path.relpath(submission_path)}")
26156 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
26157
26158 if skipped:
26159 for stage_name, reason in skipped:
26160 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
26161
26162 graceful = bool(getattr(args, "graceful", False))
26163 failures = []
26164 for job_id, stage_names in job_to_stages.items():
26165 joined_stage_names = ", ".join(stage_names)
26166 use_graceful_signal = graceful and "solve" in stage_names
26167 scancel_cmd = ["scancel"]
26168 if use_graceful_signal:
26169 # A directly launched ``mpirun`` replaces the batch shell (via ``exec``)
26170 # and is therefore not necessarily a Slurm job step. ``--full`` also
26171 # signals the batch process and its children, so the MPI launcher can
26172 # forward SIGUSR1 to the solver ranks.
26173 scancel_cmd.extend(["--signal=USR1", "--full"])
26174 scancel_cmd.append(job_id)
26175
26176 if args.dry_run:
26177 print(f"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
26178 continue
26179
26180 # scancel exits 0 for a job that already ended, so ask Slurm before claiming a cancel.
26181 status, end_state = slurm_job_end_state(job_id)
26182 # Every status but "active" with a recorded end state is a job Slurm has finished.
26183 if end_state and status != "active":
26184 print(f"[INFO] Slurm job {job_id} for stage(s) {joined_stage_names} already ended "
26185 f"({end_state}); nothing to cancel.")
26186 continue
26187
26188 result = subprocess.run(scancel_cmd, text=True, capture_output=True, check=False)
26189 stderr_text = (result.stderr or "").strip()
26190 stdout_text = (result.stdout or "").strip()
26191 if result.returncode == 0:
26192 if use_graceful_signal:
26193 print(
26194 f"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
26195 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
26196 )
26197 else:
26198 print(f"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
26199 continue
26200
26201 detail = stderr_text or stdout_text or "unknown scancel failure"
26202 failures.append((job_id, joined_stage_names, detail, result.returncode))
26203 print(
26204 f"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
26205 file=sys.stderr,
26206 )
26207
26208 if args.dry_run:
26209 print("[INFO] Dry-run only. No jobs were canceled.")
26210 return
26211
26212 if failures:
26213 sys.exit(1)
26214
26215
26217 """!
26218 @brief Infer the owned workspace role of one copied YAML file.
26219 @param[in] path YAML file copied from an example template.
26220 @return One of case, solver, monitor, post, cluster, study, or None.
26221 """
26222 try:
26223 payload = read_yaml_file(path)
26224 except (OSError, ValueError):
26225 return None
26226 keys = set(payload)
26227 if {"grid", "properties", "run_control"} <= keys:
26228 return "case"
26229 if "base_configs" in keys and ("study_type" in keys or "parameters" in keys or "parameter_sets" in keys):
26230 return "study"
26231 if "scheduler" in keys and "resources" in keys:
26232 return "cluster"
26233 # Any key only a post recipe owns; a spectra- or statistics-only recipe has no
26234 # pipeline, and source_data is optional. run_control is shared with case.yml.
26235 post_only = _POST_SCHEMA[()] - _MONITOR_SCHEMA[()] - {"run_control"}
26236 if keys & post_only or ("run_control" in keys and not keys & _CASE_SCHEMA[()] - {"run_control", "title"}):
26237 return "post"
26238 if "io" in keys and ("logging" in keys or "profiling" in keys or "diagnostics" in keys):
26239 return "monitor"
26240 if "momentum_solver" in keys or "poisson_solver" in keys or "operation_mode" in keys:
26241 return "solver"
26242 return None
26243
26244
26245def _rewrite_workspace_path_values(value, replacements: dict):
26246 """!
26247 @brief Rewrite copied template path scalars to workspace-root-relative homes.
26248 @param[in] value YAML subtree to rewrite.
26249 @param[in] replacements Old relative/basename paths mapped to new workspace paths.
26250 @return Rewritten YAML subtree.
26251 """
26252 if isinstance(value, dict):
26253 return {key: _rewrite_workspace_path_values(item, replacements) for key, item in value.items()}
26254 if isinstance(value, list):
26255 return [_rewrite_workspace_path_values(item, replacements) for item in value]
26256 if not isinstance(value, str):
26257 return value
26258 normalized = value.replace("\\", "/").lstrip("./")
26259 return replacements.get(normalized, replacements.get(os.path.basename(normalized), value))
26260
26261
26262def _choose_primary_workspace_role(candidates: list, role: str, template_name: str):
26263 """!
26264 @brief Select the canonical role file from a template that may carry variants.
26265 @param[in] candidates Candidate absolute YAML paths.
26266 @param[in] role Config role being selected.
26267 @param[in] template_name Source example directory name.
26268 @return Selected absolute path or None.
26269 """
26270 if not candidates:
26271 return None
26272 preferred = {
26273 "case": ("case.yml", f"{template_name}.yml", "master_case.yml"),
26274 "solver": ("solver.yml", "Imp-MG-Standard.yml", "master_solver.yml"),
26275 "monitor": ("monitor.yml", "Standard_Output.yml", "master_monitor.yml"),
26276 "post": ("post.yml", "standard_analysis.yml", "master_postprocessor.yml"),
26277 "cluster": ("cluster.yml", "slurm_cluster.yml", "master_cluster.yml"),
26278 }.get(role, ())
26279 by_name = {os.path.basename(path): path for path in candidates}
26280 for name in preferred:
26281 if name in by_name:
26282 return by_name[name]
26283 return sorted(candidates)[0]
26284
26285
26286def organize_initialized_workspace(workspace_root: str, template_name: str,
26287 source_template_root: str = None) -> dict:
26288 """!
26289 @brief Convert a copied example into the canonical editable workspace layout.
26290 @param[in] workspace_root Newly copied workspace root.
26291 @param[in] template_name Example template identity.
26292 @param[in] source_template_root Optional original template root used to vendor references.
26293 @return Mapping of canonical config roles and relocated imported inputs.
26294 """
26295 workspace_root = os.path.abspath(workspace_root)
26296 ensure_workspace_layout(workspace_root)
26297 # Skip the workspace's own runs/ and studies/ trees, judged inside the workspace: a
26298 # workspace that itself sits under a directory named runs/ must still be organized.
26299 yaml_files = [
26300 str(path)
26301 for pattern in ("*.yml", "*.yaml")
26302 for path in Path(workspace_root).rglob(pattern)
26303 if path.name not in {RUNTIME_EXECUTION_EXAMPLE_FILENAME, RUNTIME_EXECUTION_CONFIG_FILENAME}
26304 and not {"runs", "studies"} & set(path.relative_to(workspace_root).parts)
26305 ]
26306 vendored_replacements = {}
26307 if source_template_root:
26308 source_template_root = os.path.abspath(source_template_root)
26309
26310 def vendor_references(value, source_yaml: str, dotted: str = ""):
26311 """!
26312 @brief Vendor referenced generator inputs into canonical workspace homes.
26313 @param[in] value YAML subtree to inspect.
26314 @param[in] source_yaml Original template YAML path.
26315 @param[in] dotted Current dotted key path.
26316 @return None.
26317 """
26318 if isinstance(value, dict):
26319 for key, child in value.items():
26320 vendor_references(child, source_yaml, f"{dotted}.{key}" if dotted else str(key))
26321 return
26322 if isinstance(value, list):
26323 for index, child in enumerate(value):
26324 vendor_references(child, source_yaml, f"{dotted}[{index}]")
26325 return
26326 key = dotted.rsplit(".", 1)[-1]
26327 if key not in _VENDORABLE_CONFIG_REFERENCE_KEYS or not isinstance(value, str) or not value.strip():
26328 return
26329 origin = os.path.abspath(os.path.join(os.path.dirname(source_yaml), value))
26330 if not os.path.isfile(origin):
26331 return
26332 if key == "script":
26333 home = "config/generators"
26334 elif "grid" in dotted:
26335 home = "config/grids"
26336 elif "initial_condition" in dotted:
26337 home = "config/initial_conditions"
26338 elif "inlet" in dotted or "boundary_conditions" in dotted:
26339 home = "config/inlet_profiles"
26340 else:
26341 home = "config"
26342 destination = os.path.join(workspace_root, *home.split("/"), os.path.basename(origin))
26343 os.makedirs(os.path.dirname(destination), exist_ok=True)
26344 if not os.path.exists(destination):
26345 shutil.copy2(origin, destination)
26346 relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
26347 vendored_replacements[value.replace("\\", "/").lstrip("./")] = relative
26348 vendored_replacements[os.path.basename(value)] = relative
26349
26350 for copied_yaml in sorted(set(yaml_files)):
26351 relative = os.path.relpath(copied_yaml, workspace_root)
26352 source_yaml = os.path.join(source_template_root, relative)
26353 if not os.path.isfile(source_yaml):
26354 continue
26355 try:
26356 vendor_references(read_yaml_file(copied_yaml), source_yaml)
26357 except (OSError, ValueError):
26358 continue
26359 roles = {}
26360 for path in sorted(set(yaml_files)):
26361 role = _workspace_yaml_role(path)
26362 if role:
26363 roles.setdefault(role, []).append(path)
26364
26365 selected = {
26366 role: _choose_primary_workspace_role(paths, role, template_name)
26367 for role, paths in roles.items() if role != "study"
26368 }
26369 destinations = {}
26370 occupied = set()
26371 for role, paths in roles.items():
26372 for source in paths:
26373 if role == "study":
26374 relative = os.path.join("config", "studies", os.path.basename(source))
26375 elif source == selected.get(role):
26376 relative = os.path.join("config", f"{role}.yml")
26377 else:
26378 relative = os.path.join("config", os.path.basename(source))
26379 destination = os.path.join(workspace_root, relative)
26380 if os.path.abspath(source) == os.path.abspath(destination):
26381 destinations[source] = relative.replace(os.sep, "/")
26382 occupied.add(os.path.abspath(destination))
26383 continue
26384 if os.path.abspath(destination) in occupied or os.path.exists(destination):
26385 original_relative = os.path.relpath(source, workspace_root)
26386 relative = os.path.join("config", "variants", original_relative)
26387 destination = os.path.join(workspace_root, relative)
26388 counter = 2
26389 while os.path.abspath(destination) in occupied or os.path.exists(destination):
26390 stem, suffix = os.path.splitext(relative)
26391 destination = os.path.join(workspace_root, f"{stem}-{counter}{suffix}")
26392 counter += 1
26393 os.makedirs(os.path.dirname(destination), exist_ok=True)
26394 shutil.move(source, destination)
26395 destinations[source] = relative.replace(os.sep, "/")
26396 occupied.add(os.path.abspath(destination))
26397
26398 input_extensions = {
26399 ".picgrid": "inputs/grids",
26400 ".vts": "inputs/grids",
26401 ".picslice": "inputs/inlet_profiles",
26402 ".dat": "inputs/initial_conditions",
26403 }
26404 input_moves = {}
26405 for path in sorted(Path(workspace_root).rglob("*")):
26406 if not path.is_file() or path.suffix.lower() not in input_extensions:
26407 continue
26408 if any(part in _WORKSPACE_MANAGED_PATHS for part in path.relative_to(workspace_root).parts):
26409 continue
26410 destination_dir = os.path.join(workspace_root, *input_extensions[path.suffix.lower()].split("/"))
26411 destination = os.path.join(destination_dir, path.name)
26412 if os.path.exists(destination):
26413 continue
26414 old_relative = path.relative_to(workspace_root).as_posix()
26415 shutil.move(str(path), destination)
26416 new_relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
26417 input_moves[old_relative] = new_relative
26418 input_moves[path.name] = new_relative
26419
26420 config_replacements = {}
26421 basename_counts = {}
26422 for source in destinations:
26423 basename = os.path.basename(source)
26424 basename_counts[basename] = basename_counts.get(basename, 0) + 1
26425 for source, relative in destinations.items():
26426 old_relative = os.path.relpath(source, workspace_root).replace(os.sep, "/")
26427 config_replacements[old_relative] = relative
26428 if basename_counts[os.path.basename(source)] == 1:
26429 config_replacements[os.path.basename(source)] = relative
26430 replacements = {**config_replacements, **input_moves, **vendored_replacements}
26431 for path in sorted(Path(workspace_root, "config").rglob("*.yml")):
26432 payload = read_yaml_file(str(path))
26433 local_replacements = dict(replacements)
26434 original_source = next(
26435 (
26436 source for source, relative in destinations.items()
26437 if os.path.abspath(os.path.join(workspace_root, relative)) == os.path.abspath(path)
26438 ),
26439 None,
26440 )
26441 if original_source:
26442 original_parent = os.path.dirname(original_source)
26443 for candidate_source, candidate_relative in destinations.items():
26444 relative_reference = os.path.relpath(candidate_source, original_parent).replace(os.sep, "/")
26445 local_replacements[relative_reference] = candidate_relative
26446 if os.path.dirname(candidate_source) == original_parent:
26447 local_replacements[os.path.basename(candidate_source)] = candidate_relative
26448 rewritten = _rewrite_workspace_path_values(payload, local_replacements)
26449 role = _workspace_yaml_role(str(path))
26450 if role == "monitor" and isinstance(rewritten.get("io"), dict):
26451 rewritten["io"].pop("directories", None)
26452 if role == "case" and path == Path(workspace_root, "config", "case.yml"):
26453 rewritten.setdefault("title", template_name)
26454 if role == "post":
26455 if isinstance(rewritten.get("source_data"), dict):
26456 rewritten["source_data"].pop("directory", None)
26457 if isinstance(rewritten.get("io"), dict):
26458 rewritten["io"].pop("output_directory", None)
26459 if role == "study" and isinstance(rewritten.get("base_configs"), dict):
26460 for role in ("case", "solver", "monitor", "post"):
26461 canonical = os.path.join("config", f"{role}.yml").replace(os.sep, "/")
26462 if os.path.isfile(os.path.join(workspace_root, canonical)):
26463 rewritten["base_configs"][role] = canonical
26464 write_yaml_file(str(path), rewritten)
26465
26466 return {
26467 "workspace_config": initialize_workspace_root(workspace_root, template_name),
26468 "canonical_roles": {
26469 role: os.path.join(workspace_root, "config", f"{role}.yml")
26470 for role in ("case", "solver", "monitor", "post", "cluster")
26471 if os.path.isfile(os.path.join(workspace_root, "config", f"{role}.yml"))
26472 },
26473 "input_moves": input_moves,
26474 }
26475
26476
26477WORKSPACE_INPUT_DIRECTORIES = {
26478 "grid": "inputs/grids",
26479 "initial-condition": "inputs/initial_conditions",
26480 "inlet-profile": "inputs/inlet_profiles",
26481 "reference-field": "inputs/reference_fields",
26482}
26483
26484
26485def import_workspace_input(workspace_root: str, kind: str, source: str,
26486 name: str = None, mode: str = "copy") -> dict:
26487 """!
26488 @brief Explicitly import or register one workspace input.
26489 @param[in] workspace_root Initialized workspace root.
26490 @param[in] kind Semantic input kind.
26491 @param[in] source Existing source file path.
26492 @param[in] name Optional destination basename.
26493 @param[in] mode Copy, reflink, hardlink, or external-reference mode.
26494 @return Catalog entry describing the durable input identity.
26495 """
26496 workspace_root = os.path.abspath(workspace_root)
26497 load_workspace_config(workspace_root)
26498 ensure_workspace_layout(workspace_root)
26499 if kind not in WORKSPACE_INPUT_DIRECTORIES:
26500 raise ValueError(f"Unsupported input kind: {kind}")
26501 source = os.path.abspath(os.path.expanduser(source))
26502 if not os.path.isfile(source):
26503 raise ValueError(f"Input source is not a file: {source}")
26504 basename = name or os.path.basename(source)
26505 if basename != os.path.basename(basename) or basename in _PLAIN_FILENAME_SENTINELS:
26506 raise ValueError("--name must be a plain filename without directory traversal.")
26507 target_dir = os.path.join(workspace_root, *WORKSPACE_INPUT_DIRECTORIES[kind].split("/"))
26508 os.makedirs(target_dir, exist_ok=True)
26509 if mode == "reference":
26510 destination = os.path.join(target_dir, basename + ".reference.yml")
26511 write_yaml_file(destination, {
26512 "schema_version": 1,
26513 "picurv_external_reference": source,
26514 "sha256_at_registration": _asset_file_sha256(source),
26515 "bytes_at_registration": os.path.getsize(source),
26516 })
26517 else:
26518 destination = os.path.join(target_dir, basename)
26519 if os.path.exists(destination):
26520 raise ValueError(f"Workspace input already exists: {destination}")
26521 temporary = f"{destination}.tmp.{os.getpid()}"
26522 try:
26523 if mode == "copy":
26524 shutil.copy2(source, temporary)
26525 elif mode == "hardlink":
26526 os.link(source, temporary)
26527 elif mode == "reflink":
26528 cp = shutil.which("cp")
26529 if not cp:
26530 raise ValueError("reflink mode requires the 'cp' command.")
26531 result = subprocess.run(
26532 [cp, "--reflink=always", "--preserve=mode,timestamps", source, temporary],
26533 text=True, capture_output=True, check=False,
26534 )
26535 if result.returncode != 0:
26536 raise ValueError((result.stderr or "reflink copy failed").strip())
26537 else:
26538 raise ValueError(f"Unsupported import mode: {mode}")
26539 os.replace(temporary, destination)
26540 finally:
26541 if os.path.lexists(temporary):
26542 os.remove(temporary)
26543 relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
26544 entry_id = _stable_mapping_sha256({"kind": kind, "path": relative, "source": source})[:16]
26545 catalog_path = os.path.join(workspace_root, "inputs", "catalog.yml")
26546 catalog = read_yaml_file(catalog_path) if os.path.isfile(catalog_path) else {
26547 "schema_version": 1, "inputs": {}
26548 }
26549 catalog.setdefault("inputs", {})[entry_id] = {
26550 "kind": kind,
26551 "path": relative,
26552 "mode": mode,
26553 "sha256": _asset_file_sha256(source),
26554 "bytes": os.path.getsize(source),
26555 "source": source if mode == "reference" else None,
26556 "registered_at": datetime.now().astimezone().isoformat(),
26557 }
26558 write_yaml_file(catalog_path, catalog)
26559 return {"id": entry_id, **catalog["inputs"][entry_id]}
26560
26561
26563 """!
26564 @brief Handle explicit workspace input management.
26565 @param[in] args Parsed inputs command arguments.
26566 @return None.
26567 """
26568 workspace_root = os.path.abspath(args.workspace) if args.workspace else find_workspace_root(os.getcwd())
26569 if not workspace_root:
26570 raise ValueError("No initialized workspace found; run picurv init first or pass --workspace.")
26571 if args.inputs_action != "import":
26572 raise ValueError(f"Unsupported inputs action: {args.inputs_action}")
26573 entry = import_workspace_input(
26574 workspace_root, args.kind, args.source, name=args.name, mode=args.mode
26575 )
26576 print(f"[SUCCESS] Registered input {entry['id']}: {entry['path']}")
26577 if entry["mode"] == "reference":
26578 print("[WARNING] This is an external reference. Storage will record it but will not copy or prune its target.")
26579
26580
26582 """!
26583 @brief Report, and for the `status` action validate, the shared build identity.
26584
26585 @details Bare `picurv version` reports and always succeeds; it is an informational
26586 surface that scripts and documentation already depend on. `picurv version
26587 status` additionally exits non-zero when the conductor, the executables,
26588 and the workspace requirement do not agree, so a job script can refuse to
26589 launch a run whose provenance would be incoherent.
26590 @param[in] args Parsed version command arguments.
26591 @return None.
26592 """
26593 workspace_root = find_workspace_root(os.getcwd())
26594 payload = dict(picurv_build())
26595 payload["source_root"] = PACKAGE_PROJECT_ROOT
26596 payload["workspace"] = workspace_root
26597 payload["workspace_requirement"] = None
26598 if workspace_root:
26599 software = load_workspace_config(workspace_root).get("software") or {}
26600 payload["workspace_requirement"] = software.get("picurv") if isinstance(software, dict) else None
26601 payload["binaries"] = runtime_build_identities()
26602 for identity in payload["binaries"].values():
26603 library = resolved_petsc_library(identity["path"]) if identity.get("path") else None
26604 identity["petsc_library"] = library
26605 identity["petsc_library_mismatch"] = petsc_library_mismatch(identity.get("petsc"), library)
26606 validating = getattr(args, "version_action", None) == "status"
26607 problems = build_identity_problems(payload["binaries"], payload["workspace_requirement"])
26608 payload["coherent"] = not problems
26609 payload["problems"] = problems
26610 if getattr(args, "output_format", "text") == "json":
26611 print(json.dumps(payload, indent=2, sort_keys=True))
26612 if validating and problems:
26613 sys.exit(1)
26614 return
26615 print(f"PICurv release : {payload['release_version']}")
26616 print(f"Build identity : {payload['build_id']}")
26617 print(f"Git commit : {payload.get('git_commit') or 'unavailable'}")
26618 print(f"Dirty tree : {payload.get('dirty') if payload.get('dirty') is not None else 'unknown'}")
26619 if workspace_root:
26620 print(f"Workspace : {workspace_root}")
26621 print(f"Requirement : {payload['workspace_requirement'] or 'latest active version'}")
26622 print("\nNative executables")
26623 for name, identity in sorted(payload["binaries"].items()):
26624 if not identity.get("available"):
26625 print(f" {name:<15}: unavailable ({identity.get('reason', 'unknown')})")
26626 else:
26627 agreement = "matches source" if identity["matches_source"] else "STALE - rebuild"
26628 print(f" {name:<15}: {identity['build_id']} ({agreement})")
26629 _print_petsc_build(identity)
26630 if not problems:
26631 print("\nBuild identity is coherent.")
26632 return
26633 print("\nBuild identity is NOT coherent:", file=sys.stderr)
26634 for problem in problems:
26635 print(f" - {problem}", file=sys.stderr)
26636 if validating:
26637 sys.exit(1)
26638
26639
26640def _print_petsc_build(identity: dict) -> None:
26641 """!
26642 @brief Print the PETSc an executable was built against and the library it would load.
26643 @param[in] identity One executable's entry from `runtime_build_identities()`, with
26644 `petsc_library` and `petsc_library_mismatch` filled in.
26645 @return None.
26646 """
26647 petsc = identity.get("petsc")
26648 if not petsc:
26649 print(f" {'':<15} PETSc: not recorded (built before PETSc stamping)")
26650 return
26651 location = ", ".join(part for part in (petsc.get("arch"), petsc.get("dir")) if part)
26652 bits = 8 * PETSC_INDEX_BYTES[petsc.get("indices", "int32")]
26653 print(f" {'':<15} PETSc: {petsc['version']} {petsc['mode']}, {bits}-bit indices ({location})")
26654 library = identity.get("petsc_library")
26655 if library:
26656 flag = (" <- NOT the PETSc it was built against; load that PETSc's module, "
26657 "or rebuild under this one") if identity["petsc_library_mismatch"] else ""
26658 print(f" {'':<15} loads: {library}{flag}")
26659
26660
26661def _require_clean_source_checkout(action: str) -> None:
26662 """!
26663 @brief Refuse version-changing Git operations in a dirty source checkout.
26664 @param[in] action User-facing action name.
26665 @return None.
26666 """
26667 result = subprocess.run(
26668 ["git", "status", "--porcelain"], cwd=PACKAGE_PROJECT_ROOT,
26669 text=True, capture_output=True, check=False,
26670 )
26671 if result.returncode != 0:
26672 raise ValueError(f"{action}: cannot inspect the PICurv Git checkout.")
26673 if result.stdout.strip():
26674 raise ValueError(
26675 f"{action}: the PICurv source checkout has uncommitted changes. "
26676 "Commit or stash them before changing versions."
26677 )
26678
26679
26680def _git_source_command(arguments: list) -> subprocess.CompletedProcess:
26681 """!
26682 @brief Run a checked Git command against the active PICurv source checkout.
26683 @param[in] arguments Git arguments excluding the executable.
26684 @return Completed successful Git process.
26685 """
26686 result = subprocess.run(
26687 ["git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
26688 text=True, capture_output=True, check=False,
26689 )
26690 if result.returncode != 0:
26691 raise ValueError((result.stderr or result.stdout or "Git command failed.").strip())
26692 return result
26693
26694
26696 """!
26697 @brief Fetch source history without silently changing the active code.
26698 @param[in] args Parsed source command arguments.
26699 @return None.
26700 """
26701 if args.source_action != "update":
26702 raise ValueError(f"Unsupported source action: {args.source_action}")
26703 _git_source_command(["fetch", "--tags", "--prune", args.remote])
26704 print(f"[SUCCESS] Fetched branches and tags from {args.remote}; active checkout was not changed.")
26705
26706
26707def _workspace_requested_version(workspace_root: str):
26708 """!
26709 @brief Resolve an exact version requested by an initialized workspace.
26710 @param[in] workspace_root Initialized workspace root.
26711 @return Exact version or tag text.
26712 """
26713 software = load_workspace_config(workspace_root).get("software") or {}
26714 requirement = software.get("picurv") if isinstance(software, dict) else None
26715 if not requirement:
26716 raise ValueError(
26717 f"{workspace_root}/{WORKSPACE_CONFIG_FILENAME} has no software.picurv pin; "
26718 "name a version explicitly."
26719 )
26720 if any(token in str(requirement) for token in "<>=!~,*"):
26721 raise ValueError(
26722 "Automatic activation needs an exact release/tag, not a version range. "
26723 f"Pass the desired version explicitly (workspace requires {requirement!r})."
26724 )
26725 return str(requirement)
26726
26727
26729 """!
26730 @brief List or activate a release using the existing source/build owners.
26731 @param[in] args Parsed versions command arguments.
26732 @return None.
26733 """
26734 action = args.versions_action
26735 if action == "list":
26736 result = _git_source_command(["tag", "--list", "--sort=-version:refname"])
26737 print(f"Active: {picurv_build()['build_id']}")
26738 tags = [line for line in result.stdout.splitlines() if line.strip()]
26739 if tags:
26740 print("Installed/available tags:")
26741 for tag in tags:
26742 print(f" {tag}")
26743 else:
26744 print("No version tags are present in this checkout.")
26745 return
26746 version = getattr(args, "version", None)
26747 make_args = list(getattr(args, "make_args", None) or [])
26748 if version and (_MAKE_ASSIGNMENT_PATTERN.match(str(version)) or str(version).startswith("-")):
26749 # `versions activate SYSTEM=cluster` and `versions activate -- -j8` bind the
26750 # make argument to the optional version positional. No tag or commit contains
26751 # '=' or starts with '-', so either is a make argument; handing '-j8' to git as
26752 # a ref printed git's usage instead.
26753 make_args.insert(0, str(version))
26754 version = None
26755 if make_args_include_explicit_goal(make_args):
26756 raise ValueError(
26757 f"versions {action} builds the default target; pass only make variables and "
26758 f"options (for example SYSTEM=cluster), not a target: {' '.join(make_args)}"
26759 )
26760 if action == "activate" and not version:
26761 workspace_root = os.path.abspath(args.workspace) if args.workspace else find_workspace_root(os.getcwd())
26762 if not workspace_root:
26763 raise ValueError("No workspace found and no version was named.")
26764 version = _workspace_requested_version(workspace_root)
26765 if not version:
26766 raise ValueError(f"versions {action}: name the tag or commit to install.")
26767 if action not in _VERSION_BUILD_ACTIONS:
26768 raise ValueError(f"Unsupported versions action: {action}")
26769 _require_clean_source_checkout(f"versions {action}")
26770 # There is one installation, and this rewrites it in place. Anything resolving
26771 # executables from it - other workspaces, and any running job that did not pin its
26772 # binaries - is changed by this too, so say what is about to move before it moves.
26773 print(
26774 f"[WARNING] {PACKAGE_PROJECT_ROOT} is a single shared installation. Building "
26775 f"{version!r} here re-points every workspace that resolves executables from it, "
26776 "including any job already running against them.",
26777 file=sys.stderr,
26778 )
26779 print(
26780 " Runs staged with --pin-executables keep their own copies and are unaffected; "
26781 "unpinned queued jobs stop at their job-start identity check.",
26782 file=sys.stderr,
26783 )
26784 _git_source_command(["fetch", "--tags", "origin"])
26785 ref = _resolve_version_ref(str(version))
26786 _git_source_command(["checkout", "--detach", ref])
26787 run_project_make(PACKAGE_PROJECT_ROOT, make_args)
26788 # picurv_build() was computed when this process started, before the checkout moved,
26789 # so the source identity the new binaries must match is read again here.
26791 problems = build_identity_problems(
26792 runtime_build_identities(source_identity), source_identity=source_identity
26793 )
26794 if problems:
26795 raise ValueError(
26796 f"Built {ref!r}, but the executables do not carry its identity "
26797 f"({source_identity['build_id']}):\n - " + "\n - ".join(problems)
26798 )
26799 print(f"[SUCCESS] Activated and built PICurv {ref} ({source_identity['build_id']}).")
26800
26801
26802def _resolve_version_ref(version: str) -> str:
26803 """!
26804 @brief Resolve a user-named version to a Git ref that exists in the source checkout.
26805 @details Release tags are spelled `v<release>`, while `VERSION` and workspace
26806 `software.picurv` pins carry the bare release. The name is tried as given
26807 first, so a commit, branch, or already-prefixed tag is never rewritten.
26808 @param[in] version Tag, bare release, or commit named by the user or workspace pin.
26809 @return Ref accepted by `git checkout --detach`.
26810 @throws ValueError when neither spelling resolves to a commit.
26811 """
26812 candidates = [version]
26813 if not version.startswith("v"):
26814 candidates.append(f"v{version}")
26815 for candidate in candidates:
26816 result = subprocess.run(
26817 ["git", "rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}"],
26818 cwd=PACKAGE_PROJECT_ROOT, text=True, capture_output=True, check=False,
26819 )
26820 if result.returncode == 0:
26821 return candidate
26822 raise ValueError(
26823 f"No tag or commit named {' or '.join(repr(c) for c in candidates)} exists in "
26824 f"{PACKAGE_PROJECT_ROOT}; 'picurv versions list' shows the available tags."
26825 )
26826
26827
26828def init_case(args):
26829 """!
26830 @brief Implements the 'init' command.
26831 @details Creates a new case study directory by copying a template.
26832 Runtime binaries are resolved from the project bin/ directory
26833 via PATH; pass --pin-binaries to pin specific versions locally.
26834 @param[in] args The command-line arguments parsed by argparse.
26835 """
26836 context = resolve_case_origin_context(source_root_override=getattr(args, "source_root", None))
26837 try:
26838 source_project_root = require_project_root(context["source_project_root"], "init")
26839 template_path = resolve_template_directory(source_project_root, args.template_name)
26840 except ValueError as exc:
26841 print(f"[FATAL] {exc}", file=sys.stderr)
26842 sys.exit(1)
26843
26844 # The destination path is relative to the current working directory.
26845 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name if args.dest_name else args.template_name))
26846
26847 if os.path.exists(dest_path):
26848 print(f"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
26849 sys.exit(1)
26850
26851 print(f"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
26852
26853 try:
26854 workspace_layout = render_template_workspace(template_path, args.template_name, dest_path)
26855 template_managed_files = list_rendered_template_files(dest_path)
26856 print(f"[SUCCESS] Copied template files to: {dest_path}")
26857 print(f"[INFO] Wrote workspace identity: {os.path.relpath(workspace_layout['workspace_config'])}")
26858 if workspace_layout["canonical_roles"]:
26859 print("[INFO] Canonical editable configurations:")
26860 for role, path in sorted(workspace_layout["canonical_roles"].items()):
26861 print(f" - {role}: {os.path.relpath(path)}")
26862 except Exception as exc:
26863 print(f"[FATAL] Failed to create canonical workspace layout: {exc}", file=sys.stderr)
26864 shutil.rmtree(dest_path, ignore_errors=True)
26865 sys.exit(1)
26866
26867 try:
26868 runtime_result = ensure_case_runtime_execution_config(dest_path, source_project_root, overwrite=True)
26869 print(f"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
26870 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
26871 print(" Seeded from repo-local '.picurv-execution.yml'.")
26872 print(" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
26873 except Exception as e:
26874 print(f"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
26875
26876 try:
26877 metadata_path, _ = write_case_origin_metadata(
26878 dest_path,
26879 source_project_root,
26880 template_name=args.template_name,
26881 template_managed_files=template_managed_files,
26882 )
26883 print(f"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
26884 except Exception as e:
26885 print(f"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
26886
26887 cluster_profile_candidates = sorted(
26888 {
26889 os.path.basename(path)
26890 for pattern in ("*cluster*.yml", "*cluster*.yaml")
26891 for search_root in (dest_path, os.path.join(dest_path, "config"))
26892 for path in glob.glob(os.path.join(search_root, pattern))
26893 }
26894 )
26895 if cluster_profile_candidates:
26896 print("[INFO] Cluster profile sample(s) copied with this case:")
26897 for profile_name in cluster_profile_candidates:
26898 print(f" - {profile_name}")
26899 print(" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
26900
26901 if getattr(args, "pin_binaries", False):
26902 print("[INFO] Pinning runtime binaries into case directory...")
26903 try:
26904 copied_binaries = sync_case_binaries(dest_path, source_project_root)
26905 for dest_file_path in copied_binaries:
26906 print(f" - Pinned '{os.path.basename(dest_file_path)}'")
26907 print("[SUCCESS] Case directory is ready with pinned binaries.")
26908 print(" These local copies will be used instead of bin/ originals.")
26909 except ValueError as exc:
26910 print(f"[WARNING] {exc}", file=sys.stderr)
26911 print(" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
26912 else:
26913 print("[SUCCESS] Case directory is ready.")
26914 print(" Runtime binaries (simulator, postprocessor) are resolved from the active PICurv installation.")
26915 print(" Pin software.picurv in .picurv-workspace.yml only when this workspace needs a release constraint.")
26916 print(" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
26917
26918
26920 """!
26921 @brief Refresh template-managed config/docs files in a case directory.
26922 @param[in] args Command-line style argument list supplied to the function.
26923 """
26924 try:
26926 case_dir_hint=getattr(args, "case_dir", None),
26927 source_root_override=getattr(args, "source_root", None),
26928 template_name_override=getattr(args, "template_name", None),
26929 )
26930 source_project_root = require_project_root(context["source_project_root"], "sync-config")
26931 case_dir = require_existing_case_dir(context["case_dir"], "sync-config", source_project_root)
26932 template_name = context.get("template_name")
26933 template_dir = resolve_template_directory(source_project_root, template_name)
26934 existing_managed = context.get("metadata", {}).get("template_managed_files")
26935 if not isinstance(existing_managed, list):
26936 existing_managed = None
26937 summary = sync_case_template_files(
26938 case_dir,
26939 template_dir,
26940 overwrite=getattr(args, "overwrite", False),
26941 prune=getattr(args, "prune", False),
26942 managed_rel_paths=existing_managed,
26943 template_name=template_name,
26944 )
26945 metadata_path, _ = write_case_origin_metadata(
26946 case_dir,
26947 source_project_root,
26948 template_name=template_name,
26949 existing=context.get("metadata"),
26950 template_managed_files=summary["template_managed_files"],
26951 )
26952 runtime_result = ensure_case_runtime_execution_config(case_dir, source_project_root, overwrite=False)
26953 except ValueError as exc:
26954 print(f"[FATAL] {exc}", file=sys.stderr)
26955 sys.exit(1)
26956
26957 print(f"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
26958 print(f"[INFO] Copied new files : {len(summary['copied'])}")
26959 print(f"[INFO] Overwritten files : {len(summary['overwritten'])}")
26960 print(f"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
26961 print(f"[INFO] Already unchanged : {len(summary['unchanged'])}")
26962 print(f"[INFO] Pruned stale files : {len(summary['pruned'])}")
26963 if runtime_result["created"]:
26964 print(f"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
26965 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
26966 print("[INFO] Seed source : repo-local .picurv-execution.yml")
26967 if summary.get("prune_requested_without_tracking"):
26968 print("[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
26969 print(f"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
26970
26971
26973 """!
26974 @brief Refresh source branches in the repository resolved from a case directory.
26975 @param[in] args Command-line style argument list supplied to the function.
26976 """
26977 try:
26979 case_dir_hint=getattr(args, "case_dir", None),
26980 source_root_override=getattr(args, "source_root", None),
26981 )
26982 source_project_root = require_project_root(context["source_project_root"], "pull-source")
26983 except ValueError as exc:
26984 print(f"[FATAL] {exc}", file=sys.stderr)
26985 sys.exit(1)
26986
26987 try:
26988 _refuse_detached_source_checkout(source_project_root, "pull-source")
26989 except ValueError as exc:
26990 print(f"[FATAL] {exc}", file=sys.stderr)
26991 sys.exit(1)
26992
26993 rebase = not getattr(args, "no_rebase", False)
26994 remote = getattr(args, "remote", None)
26995 branch = getattr(args, "branch", None)
26996 current_branch_only = (
26997 getattr(args, "current_branch_only", False)
26998 or remote is not None
26999 or branch is not None
27000 )
27001
27002 if not current_branch_only:
27003 pull_all_source_branches(source_project_root, "pull-source.log", rebase=rebase)
27004 return
27005
27006 command = ["git", "pull"]
27007 if rebase:
27008 command.append("--rebase")
27009 if remote:
27010 command.append(remote)
27011 if branch:
27012 command.append(branch)
27013 elif branch:
27014 command.extend(["origin", branch])
27015
27016 execute_command(command, source_project_root, "pull-source.log", {})
27017
27019 """!
27020 @brief Implements the 'build' command.
27021 @details Executes the top-level Makefile directly, passing through any
27022 additional arguments to `make`. This allows for building,
27023 cleaning, and other Makefile targets via the orchestrator
27024 without maintaining a separate build wrapper script.
27025 @param[in] args The command-line arguments parsed by argparse.
27026 """
27027
27028 print("\n" + "="*27 + " BUILD STAGE " + "="*27)
27029 try:
27031 case_dir_hint=getattr(args, "case_dir", None),
27032 source_root_override=getattr(args, "source_root", None),
27033 )
27034 source_project_root = require_project_root(context["source_project_root"], "build")
27035 except ValueError as exc:
27036 print(f"[FATAL] {exc}", file=sys.stderr)
27037 sys.exit(1)
27038
27039 run_project_make(source_project_root, list(args.make_args or []))
27040
27041
27042def run_project_make(source_project_root: str, make_args: "list[str]") -> None:
27043 """!
27044 @brief Run the project Makefile with caller-supplied arguments, defaulting to `all`.
27045 @details Shared by `picurv build` and `picurv versions install/activate`, so a site
27046 setting such as `SYSTEM=cluster` reaches `make` the same way from both.
27047 @param[in] source_project_root PICurv source checkout containing the Makefile.
27048 @param[in] make_args Arguments passed through to `make`.
27049 @return None. Exits non-zero when the Makefile is missing or `make` fails.
27050 """
27051 makefile_path = os.path.join(source_project_root, "Makefile")
27052
27053 if not os.path.isfile(makefile_path):
27054 print(f"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
27055 print(" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
27056 sys.exit(1)
27057
27058 if make_args_include_explicit_goal(make_args):
27059 command = ["make"] + make_args
27060 else:
27061 command = ["make", "all"] + make_args
27062 print("[INFO] No explicit make target supplied; defaulting to 'all'.")
27063 print(" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
27064 # For the build process, we don't have a monitor.yml, so we pass an empty
27065 # dict to execute_command. The command should be run in the project root.
27066 execute_command(command, source_project_root, "build.log", {})
27067
27068
27069
27070
27071# ==============================================================================
A committed checkpoint written by a PETSc of the other index width than the driven solver's.
Definition core.py:1597
Raised when an external command exits unsuccessfully.
Definition core.py:17422
__init__(self, list command, int returncode, str details=None)
Initialize a command execution error.
Definition core.py:17427
Raised when plot.gen reports a missing optional dependency.
Definition core.py:17443
Module-like proxy that preserves picurv.np without eager import.
Definition core.py:84
__getattr__(self, name)
Resolve a NumPy attribute on first use.
Definition core.py:89
summarize_workflow(args)
Build and render a read-only health summary for a run step.
Definition core.py:25250
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:3456
dict _summarize_turbulence(dict turbulence_cfg)
Build compact turbulence and wall-model selections.
Definition core.py:22247
"tuple[dict, list[int]]" _parse_solution_convergence_log(str log_dir)
Read the solution-convergence record into rows by step plus observed order.
Definition core.py:22594
set resolve_post_stage_selection(only)
Resolve the --only selector into the set of post stages to execute.
Definition core.py:4034
"str | None" resolved_petsc_library(str executable_path)
The PETSc shared library the dynamic loader would give this executable when picurv launches it.
Definition core.py:1906
dict translate_programmatic_grid_settings(dict grid_settings)
Return programmatic-grid settings translated to the C node-count contract.
Definition core.py:13470
dict resolve_ic_payload_provenance(dict ic, int field_code)
Resolve which scales a file-backed initial condition was written in.
Definition core.py:14392
tuple validate_run_directory_containment(dict monitor_cfg, str monitor_path)
Classify legacy directory values as defense-in-depth during validation.
Definition core.py:10032
find_runtime_execution_config_file(*anchors)
Find the nearest optional execution config from runtime/case anchors.
Definition core.py:2602
int normalize_particle_init_mode(str value)
Maps canonical particle init mode names to C enum/int codes (-pinit).
Definition core.py:15705
_write_submission_target_metadata(dict target_context)
Write updated submission metadata back to disk.
Definition core.py:25620
status_source_command(args)
Report source/case drift for an initialized case directory.
Definition core.py:3249
load_ic_generator_module()
Load generators/ic.gen, which owns the expression language and the IC providers.
Definition core.py:14518
_render_monitor_summary_text(dict summary)
Render the monitor summary as a glanceable observability dashboard.
Definition core.py:23736
"tuple[str | None, list]" _particle_step_rows(str log_dir, str metrics_dir)
Return the per-step particle record's path and its (segment, row) pairs.
Definition core.py:24098
validate_workflow(args)
Implements picurv validate without launching solver/post workflows.
Definition core.py:19325
dict _normalize_square_duct_poiseuille_params(params, str field_name)
Validate square-duct Poiseuille generator parameters.
Definition core.py:7461
str retired_destination_message(str label, str key)
Explain why a generator may not choose its own output destination.
Definition core.py:3359
str _capture_command_stdout(list command, str run_dir)
Run a command, require success, and return stripped stdout text.
Definition core.py:17482
list expand_parameter_matrix(dict parameters)
Expand study parameter lists into cartesian-product combinations.
Definition core.py:11733
_render_case_summary_text(dict summary)
Render the case summary as a glanceable simulation dashboard.
Definition core.py:23623
_parse_float_loose(value)
Best-effort float parsing for summary extraction.
Definition core.py:22100
render_run_summary(dict payload, str output_format="text")
Render a run-step summary in human or JSON form.
Definition core.py:23256
dict _ensemble_source_fields(str source)
Return the field table an ensemble of one source names its fields from.
Definition core.py:9231
control_value(value, str context)
Guard a value that is written verbatim into the generated control file.
Definition core.py:16185
int normalize_les_model(value)
Maps LES model selectors to C enum/int codes (-les).
Definition core.py:15777
post_step_outputs(str run_dir, dict post_cfg, monitor_cfg=None)
Return a lookup of the output files one step of the current recipe holds.
Definition core.py:5649
int prepare_post_job(str run_dir, str post_recipe_path, str postprocessor, bool recompute=False)
Decide, as a post job starts, which steps it processes.
Definition core.py:6345
list read_text_file_lines(str path)
Read a text file into a list of lines.
Definition core.py:25758
bool needs_restart_source(dict case_cfg, dict solver_cfg)
Return True when the solver requires restart data from disk.
Definition core.py:6456
str resolve_target_grid_for_generated_profile(dict case_cfg, str case_path, str run_dir)
Resolve the target canonical PICGRID for generated profile sampling.
Definition core.py:7824
str ensure_post_lock_wrapper(str run_dir)
Ensure the lock wrapper exists for a run directory and return its path.
Definition core.py:6078
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:6552
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:11578
None warn_overlapping_post_recipes(str run_dir, dict post_cfg, dict plan, post_stages)
Warn when another recipe in this run already produced outputs this one repeats.
Definition core.py:5902
"tuple[dict, str]" compute_post_recipe_fingerprint(dict recipe_cfg)
Return normalized recipe signature plus SHA-256 fingerprint.
Definition core.py:4983
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:2942
int normalize_les_clip_mode(value)
Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
Definition core.py:15904
int select_existing_run_petsc_index_bytes(str run_dir, args=None)
Select the PetscInt width an existing run was staged with, for its later stages.
Definition core.py:15115
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:2634
list _representative_indices(int count, int maximum=6)
Select evenly distributed indices while always retaining both endpoints.
Definition core.py:24811
dict _build_run_overview(dict context)
Build timestep-independent run metadata for summarize.
Definition core.py:22224
_iter_parent_dirs(str start_path)
Yield a path and all of its parents up to filesystem root.
Definition core.py:2505
None _refuse_detached_source_checkout(str source_project_root, str action)
Refuse a branch pull when the source checkout is pinned to a detached commit.
Definition core.py:17626
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:20226
str workspace_artifact_root(str workspace_root, str kind)
Return the canonical workspace-owned root for runs or studies.
Definition core.py:960
get_post_field_statistics_artifacts(dict post_cfg, str run_dir)
Predict the per-window statistics artifacts a recipe will produce.
Definition core.py:4720
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:17654
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:3016
dict _source_build_identity(str release_version)
Resolve reproducible release, commit, and dirty-tree build identity.
Definition core.py:238
dict load_workspace_config(str workspace_root)
Load and validate the immutable workspace identity/configuration file.
Definition core.py:502
str _summary_table_path(dict context, str name)
Resolve a summarize --table name to the CSV it prints.
Definition core.py:25132
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:13308
"tuple[str, str | None]" slurm_job_end_state(str job_id)
Whether a recorded Slurm job (or array) is still queued, and how it ended if not.
Definition core.py:12401
_parse_int_loose(value)
Best-effort integer parsing for summary extraction.
Definition core.py:22080
str resolve_post_spectra_output_dir(monitor_cfg=None)
Resolve the run-relative directory spectra CSVs are written to.
Definition core.py:4477
"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:22526
bool _working_tree_has_tracked_changes(str run_dir)
Return True when the repository has staged or unstaged tracked changes.
Definition core.py:17563
_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:24499
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:21312
dict resolve_reference_scales(dict case_cfg)
Resolve the reference length and velocity (and density, when given) of a case.
Definition core.py:9647
None ensure_workspace_layout(str workspace_root)
Materialize the uniform, cheap directory skeleton for one workspace.
Definition core.py:658
list build_identity_problems(dict identities, workspace_requirement=None, "dict | None" source_identity=None)
Report every reason the active build identity is not internally coherent.
Definition core.py:2103
str normalize_statistics_task(str task_name)
Normalizes user-facing statistics task names to C pipeline keywords.
Definition core.py:7117
bool _post_requests_particle_output(dict post_cfg)
Return whether the current post recipe expects particle VTP output artifacts.
Definition core.py:5059
str _resolve_case_relative_path(str path_value, str case_dir)
Resolve a path relative to the current case directory.
Definition core.py:7741
str resolve_run_restart_dir(str run_dir, dict monitor_cfg)
Resolve the restart staging directory within a run directory.
Definition core.py:6491
extract_metric_from_csv(str case_dir, dict spec)
Extract a scalar metric from a CSV source.
Definition core.py:18072
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:8289
dict build_walltime_guard_exports("dict | None" cluster_cfg)
Build shell-evaluated environment exports for the runtime walltime guard.
Definition core.py:1815
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:6635
str _checkpoint_bundle_path(str source_dir, int step)
Resolve a run/output root or an exact checkpoint bundle.
Definition core.py:1561
int runtime_petsc_index_bytes()
Bytes of a PetscInt in the PETSc the solver this conductor drives was built on.
Definition core.py:14975
_field_statistics_window_samples(str run_dir, int step)
Read each field-statistics window's sample count from one committed checkpoint.
Definition core.py:5626
"dict | None" resolve_les_diagnostics(dict case_cfg, dict monitor_cfg, bool warn=False)
Resolve the LES diagnostic log settings from monitor.yml or their deprecated location.
Definition core.py:16074
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:2462
reduce_metric_values(values, str reduction)
Reduce a metric series to one scalar according to the requested reducer.
Definition core.py:18050
None _write_pvd_collection(str path, list entries)
Atomically write one VTK Collection file.
Definition core.py:5231
_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:25601
_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:22993
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:7925
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:23077
str _face_artifact_token(str face)
Convert a BC face token into a filesystem-friendly artifact token.
Definition core.py:7417
None record_run_petsc_index_bytes(str run_dir)
Record in config/active.json the PetscInt width the current command selected.
Definition core.py:15136
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:5006
_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:17575
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:6970
dict _existing_pvd_times(str pvd_path)
Read step-to-time fallbacks from an existing PVD collection.
Definition core.py:5171
"tuple[int, str]" required_petsc_index_bytes(dict case_cfg, str case_path, "dict | None" monitor_cfg, "str | None" staged_grid=None)
The PetscInt width a new run needs, from its particle count and grid size.
Definition core.py:15017
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:1417
list read_picgrid_header_dimensions(str source_grid, int expected_nblk=None)
Read only the canonical PICGRID header dimensions.
Definition core.py:7262
str write_software_lock(str run_dir)
Write the run's software lock beside its asset lock.
Definition core.py:948
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:7619
write_json_file(str filepath, dict payload)
Write JSON metadata/manifests with a stable, readable format.
Definition core.py:2396
"dict | None" read_binary_petsc_stamp(str executable_path)
Read the PETSc build stamp from an executable's bytes, without running it.
Definition core.py:1885
list _build_summary_plot_catalog(list records)
Build available qualified-series metadata from plot records.
Definition core.py:24787
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:25554
int finish_post_job(str run_dir, str post_recipe_path, bool exited_cleanly=False)
Record, after a post job ends, which steps it produced.
Definition core.py:6394
dict restart_grid_inputs(str run_dir, dict case_cfg)
The inputs a run's geometry is derived from, to compare across a restart.
Definition core.py:12222
list normalize_monitor_function_names(list names, str field_name)
Translate renamed solver functions in a monitor.yml function list.
Definition core.py:12544
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:17337
str classify_run_directory_value(value)
Classify a configured run directory value.
Definition core.py:9719
version_workflow(args)
Report, and for the status action validate, the shared build identity.
Definition core.py:26581
render_slurm_script(str script_path, str job_name, dict cluster_cfg, list command, str workdir, str stdout_path, str stderr_path=None, dict env_vars=None, dict shell_env_vars=None, str array_spec=None, list follow_commands=None, "tuple | None" identity_check=None)
Render a Slurm batch script for a single command.
Definition core.py:11970
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:4994
str write_profile_info(str config_dir, list summaries)
Write a profile.info summary for generated inlet profiles.
Definition core.py:7850
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:10056
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:3103
list da_processor_layout_problems(dict grid_cfg, "int | None" num_procs, "list | None" block_dims=None, "dict | None" solver_cfg=None, bool any_periodic=False, "str | None" source=None)
Problems with a user-set DMDA layout that would abort grid setup.
Definition core.py:13731
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:2817
warn_on_restart_grid_change(dict source_inputs, dict branch_inputs)
Warn at staging when a restart's grid inputs differ from its source run's.
Definition core.py:12252
str _post_finalize_python_source()
Return the importable serial finalizer used by generated jobs.
Definition core.py:5386
_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:17494
render_selected_summary(dict payload, str output_format="text")
Render selected timestep-independent config views and optional health.
Definition core.py:23786
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:3487
dict normalize_post_field_statistics_config(dict post_cfg)
Validate and canonicalize the field_statistics block of post.yml.
Definition core.py:4572
"list[tuple[str, str | None]]" _get_local_branches_with_upstreams(str run_dir)
Return local branch names plus their configured upstreams.
Definition core.py:17544
bool _launcher_arg_contains_whitespace(token)
Return True when a launcher arg token contains embedded whitespace and should be split.
Definition core.py:2431
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:6655
sweep_reaggregate_workflow(args)
Re-run metrics aggregation and plot generation for an existing study.
Definition core.py:21983
dict validate_petsc_vec_binary(str path, "int | None" index_bytes=None)
Validate the basic PETSc binary VecView envelope used by ReadFieldData.
Definition core.py:15179
_workspace_yaml_role(str path)
Infer the owned workspace role of one copied YAML file.
Definition core.py:26216
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:2233
None run_project_make(str source_project_root, "list[str]" make_args)
Run the project Makefile with caller-supplied arguments, defaulting to all.
Definition core.py:27042
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:990
int adopt_relocated_artifact(str root, str kind="run", bool dry_run=False)
Point a moved run's or study's generated files at where it now lives.
Definition core.py:1523
str get_post_step_list_path(str run_dir, dict post_cfg)
Return the file naming the steps a launched post job processes.
Definition core.py:6320
list petsc_index_arguments(int index_bytes)
Generator arguments naming a PetscInt width; none for PETSc's default 32-bit.
Definition core.py:15168
_read_runtime_diagnostics_csv(path)
Yields (segment, row) for each data line of one of the solver's CSV records.
Definition core.py:23978
_find_named_file_upwards(str start, str filename)
Find a named file at or above an arbitrary filesystem anchor.
Definition core.py:467
"tuple[dict, dict, dict]" _load_post_job_configs(str run_dir, str post_recipe_path)
Load the configuration a post job needs from the run it runs in.
Definition core.py:6330
str normalize_solution_convergence_mode(str value)
Normalizes the solution-convergence mode selector to the C-side canonical string.
Definition core.py:14327
list executable_identity_check_lines(str executable, "str | None" expected_version_line, bool pinned=False)
Shell lines that compare an executable's reported identity with the staged one.
Definition core.py:11914
"list[tuple[int, str, list]]" _newton_summary_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Newton per-step summary.
Definition core.py:24287
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:24957
str compute_post_spectra_signature(dict spectra_cfg)
Reduce a normalized spectra recipe to a stable identity string.
Definition core.py:4467
list classify_physical_containment(str run_dir, dict values)
Classify where each run-owned directory physically lands.
Definition core.py:9948
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:8410
dict archive_active_generated_configuration(str run_dir, list paths)
Preserve generated control sidecars beside a continuation's YAML revision.
Definition core.py:1279
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:621
str normalize_momentum_solver_type(str value)
Maps canonical user-facing momentum solver names to C-enum CLI values.
Definition core.py:14000
_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:17607
dict _read_previous_metric_rows(str results_dir)
Read the metrics table an earlier aggregation wrote, keyed by case id.
Definition core.py:18172
dict build_software_lock("str | None" run_dir=None)
Capture the exact software identity a run is about to execute with.
Definition core.py:861
dict plan_run_executable_paths(str run_dir, args, "str | None" workspace_root)
Predict, without writing, which executables staging would give a run.
Definition core.py:1194
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:6762
get_post_source_data(dict post_cfg)
Return source_data as a mapping when valid, else an empty mapping.
Definition core.py:3388
str _build_post_lock_wrapper_source()
Return the Python wrapper that runs one post job under an exclusive lock.
Definition core.py:5960
float _resolve_field_slice_velocity_scale(dict source, str case_dir)
Resolve field_slice dimensional velocity scale.
Definition core.py:7773
bool paths_overlap(str first, str second)
Whether two run-relative directories are the same or nested in one another.
Definition core.py:9764
None _print_petsc_build(dict identity)
Print the PETSc an executable was built against and the library it would load.
Definition core.py:26640
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:13551
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:3506
bool _post_requests_eulerian_output(dict post_cfg)
Return whether the current post recipe expects Eulerian VTK output artifacts.
Definition core.py:5049
bool is_valid_email(str email)
Lightweight email validation for scheduler notifications.
Definition core.py:7106
"int | None" resolve_statistics_console_output_frequency(dict io_cfg)
Resolve the statistics console cadence, mirroring the particle one.
Definition core.py:13176
str _materialize_asset_file(str source, str destination)
Expose one immutable shared-asset file through reflink, hardlink, or copy.
Definition core.py:20330
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:12852
dict _picgrid_geometry_summary(str path)
Read a canonical PICGRID and summarize what a user would want to check.
Definition core.py:19865
dict resolve_decomposition(int num_procs, dict grid_cfg, "list | None" block_dims, "dict | None" solver_cfg, bool any_periodic, explicit=None, "dict | None" cluster_cfg=None)
Resolve the MPI decomposition a stage launches with.
Definition core.py:13884
_read_yaml_if_exists(str filepath)
Read YAML when present, otherwise return None.
Definition core.py:22053
list warn_on_stale_runtime_binaries(dict identities)
Report native executables whose build identity is not the active source.
Definition core.py:2152
str resolve_path(str anchor_file, str candidate)
Resolve a potentially relative path against a source YAML file path.
Definition core.py:3277
normalize_ic_initial_spectra(requested, excluded_axes, generator, required)
Validate the spectra a structured IC provider measures on its own output.
Definition core.py:14718
str _post_output_directory_abs(str run_dir, dict post_cfg)
Resolve the absolute post output directory for the current recipe.
Definition core.py:5034
tuple _bc_profile_expected_dims(str face, tuple block_dims)
Return expected PICSLICE dimensions for a face and block node dimensions.
Definition core.py:8208
initial_condition_diagnostic_paths(run_dir, resolved_ic)
Resolve spectral IC artifacts through the canonical run layout, also for dry runs.
Definition core.py:15230
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:25687
dict parse_solver_config(dict solver_cfg, dict scales=None, dict case_cfg=None)
Parses the structured solver.yml into a flat dictionary of {flag: value}.
Definition core.py:16436
None _require_clean_source_checkout(str action)
Refuse version-changing Git operations in a dirty source checkout.
Definition core.py:26661
list _normalize_post_statistics_ensembles(raw)
Validate and canonicalize the ensembles a post recipe defines.
Definition core.py:4535
_require_successful_command(list command, subprocess.CompletedProcess result)
Raise CommandExecutionError when a captured command failed.
Definition core.py:17470
"tuple[dict, list[int], dict]" _parse_runtime_memory_log(str filepath)
Read the runtime memory record into rows by step and its final status.
Definition core.py:22558
discover_local_project_root(*extra_anchors)
Best-effort source repo discovery from runtime anchors.
Definition core.py:2535
_render_run_overview_text(dict summary)
Render run metadata as a compact dashboard.
Definition core.py:23595
_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:12746
list check_physical_containment(str run_dir, dict values)
Human-readable physical containment violations.
Definition core.py:10005
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:19176
"str | None" executable_petsc_library_dir(str executable_path)
The lib/ directory of the PETSc an executable was built against, from its stamp.
Definition core.py:11886
"int | None" _find_previous_snapshot_step("list[int]" snapshot_steps, int step)
Return the nearest earlier snapshot step when available.
Definition core.py:22680
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:8931
find_workspace_root(*anchors)
Locate the nearest initialized PICurv workspace for supplied anchors.
Definition core.py:487
dict resolve_post_decomposition(str run_dir, int num_procs, explicit=None, "dict | None" cluster_cfg=None)
Resolve the postprocessor's MPI decomposition from the run's staged configuration.
Definition core.py:17277
list expand_study_parameter_combinations(dict study_cfg)
Expand either cartesian-study parameters or explicit parameter sets.
Definition core.py:11747
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:3338
dict submit_sbatch(str script_path, str dependency=None, str dependency_type="afterok")
Submit sbatch script and return submission metadata.
Definition core.py:12438
"int | None" checkpoint_source_petsc_index_bytes(str source_dir)
PetscInt width the committed checkpoints under a run output root were written with.
Definition core.py:15043
_normalize_statistics_requests(str label, fields_raw, covariances_raw, dict eligible, case_cfg=None)
Validate and canonicalize the fields and covariances one window or ensemble requests.
Definition core.py:13029
str directory_value_charset_problem(str value)
Describe why a directory value cannot be written to a PETSc options line.
Definition core.py:9702
"tuple[dict, dict, list[int]]" _parse_momentum_convergence_logs(str log_dir)
Read the per-block momentum records (Picard history, Newton summary) into rows by step.
Definition core.py:22470
"int | None" resolve_particle_console_output_frequency(dict io_cfg)
Return the effective particle-console snapshot cadence from monitor.yml.
Definition core.py:16350
_extract_numeric_tuple(str text)
Extract a numeric tuple from a string like '(1, 2, 3)'.
Definition core.py:22117
None report_post_plan(dict plan)
Print which steps a post plan processes, keeps, and waits for.
Definition core.py:6299
validate_simulation_configs(dict case_cfg, dict solver_cfg, dict monitor_cfg, str case_path, str solver_path, str monitor_path, bool restart_source_given=False)
Validates every configuration a simulation run consumes, before any work is done.
Definition core.py:10151
validate_and_prepare_boundary_conditions(dict case_cfg)
Validate BC entries against currently supported C-side handlers/types and.
Definition core.py:8720
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:19762
list _runtime_memory_rows(str path)
Return the runtime memory record's (segment, row) pairs (CSV, or an older run's text log).
Definition core.py:24383
"dict | None" _infer_study_plot_axis(dict study_cfg, list rows)
Infer the scientifically meaningful independent variable of a study.
Definition core.py:18342
submit_staged_local_run(args, dict target_context, list selected_stages)
Execute previously staged local run commands from scheduler/submission.json.
Definition core.py:25974
dict resolve_ic_cli_params(dict ic, int finit_code, prepared_blocks, dict scales)
Resolve all IC parameters and return a dict of PETSc option values.
Definition core.py:15472
dict _summary_scalar_transport(dict context, dict solver_cfg)
Scalar-transport parameters as the run resolves them, wherever they are set.
Definition core.py:22347
str run_initial_condition_generator(str case_path, str run_dir, dict resolved_ic)
Run the repository IC generator.
Definition core.py:15284
init_case(args)
Implements the 'init' command.
Definition core.py:26828
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:12517
_case_reynolds_number(dict case_cfg)
Reynolds number implied by a case's scaling and fluid properties.
Definition core.py:8525
list list_rendered_template_files(str rendered_root)
List the template-managed files of a rendered workspace, workspace-relative.
Definition core.py:2984
int normalize_les_averaging_mode(value)
Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
Definition core.py:15874
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:17994
render_slurm_array_stage_script(str script_path, str job_name, dict cluster_cfg, str array_spec, str case_index_tsv, str stage, str solver_exe, str post_exe, str stdout_path, str stderr_path, "str | None" expected_version_line=None)
Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
Definition core.py:17835
append_les_parameter_flags(dict les_cfg, list control_lines)
Appends the LES closure parameter flags from a structured les block.
Definition core.py:16011
"tuple[list, dict]" build_post_locked_command(str run_dir, str recipe_fingerprint, list wrapped_command, bool create_wrapper=True, "list | None" success_command=None, "str | None" post_recipe=None, "str | None" postprocessor=None, bool recompute=False)
Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
Definition core.py:6104
str _format_optional_step(step)
Format an optional step number for user-facing diagnostics.
Definition core.py:5712
bool case_declares_periodic_axis(dict case_cfg)
Whether any face of any block is PERIODIC, which sets the DMDA stencil width.
Definition core.py:13716
"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:11001
dict runtime_build_identities("dict | None" source_identity=None, "str | None" run_dir=None)
Read the build identity of every native executable a run would launch.
Definition core.py:2054
dict load_run_executables(str run_dir)
Return the executables a run is pinned to, as recorded by its active configuration.
Definition core.py:1107
str resolve_run_output_dir(str run_dir, dict monitor_cfg)
Resolve the output data directory within a run directory.
Definition core.py:6480
int select_petsc_index_bytes(int index_bytes, str reason)
Make the current command drive the build of one PetscInt width.
Definition core.py:15059
"str | None" classify_post_step(record, files, commit, str recipe, bool recompute=False)
Decide whether one step's existing output is still valid.
Definition core.py:5870
bool discard_unused_run_directory(str run_dir, *bool created, bool staged=False)
Remove a generated run directory that never received any content.
Definition core.py:823
"tuple[str | None, list]" _solution_convergence_rows(str log_dir)
Return the solution-convergence record's path and its (segment, row) pairs.
Definition core.py:24448
dict merge_execution_overrides("dict | None" base, "dict | None" override)
Merge execution overrides, letting explicit override values win key-by-key.
Definition core.py:2727
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:26486
optional_matplotlib_pyplot()
Import matplotlib.pyplot lazily for study plot generation.
Definition core.py:162
get_post_input_extensions(dict post_cfg)
Return post input_extensions, preferring io.
Definition core.py:3410
dict build_case_asset_graph(dict case_cfg, str case_path)
Classify case inputs into precomputable or simulator-runtime providers.
Definition core.py:19566
dict render_template_workspace(str template_dir, str template_name, str destination)
Render an example template into the canonical workspace layout at a new path.
Definition core.py:2964
list _asset_selection(dict graph, requested=None, *bool precomputable_only=False)
Resolve requested asset kinds plus dependency closure.
Definition core.py:19694
dict enforce_reproducibility_policy(str workspace_root)
Enforce an optional workspace policy demanding a clean, released build.
Definition core.py:560
_split_error_file_and_message(str raw_error)
Separate a validation error into its source-file and message fields when possible.
Definition core.py:2275
str _schema_path_text(tuple path)
Render an internal schema path tuple as a user-facing YAML path.
Definition core.py:8904
list _get_recorded_submission_stages(dict target_context)
Return stage names explicitly recorded in scheduler submission metadata.
Definition core.py:25532
"tuple[dict, dict, list[int]]" _parse_poisson_convergence_logs(str log_dir)
Read the per-block Poisson histories into each step's last iteration.
Definition core.py:22510
sweep_workflow(args)
Study/sweep orchestration using Slurm job arrays.
Definition core.py:21372
list finalize_post_paraview_series(str run_dir, dict post_cfg)
Build physical-time ParaView collections from current or lineage VTK output.
Definition core.py:5284
str _summary_display_value(value)
Format one configuration-summary value for compact text output.
Definition core.py:23529
"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:12082
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:5516
str get_git_commit(str repo_root=None)
Best-effort git commit lookup for run/study manifests and case metadata.
Definition core.py:2766
list run_post_reducers_stage(str run_dir, dict post_cfg, bool quiet=False)
Run every reducer task over the profiles the recipe has written.
Definition core.py:4335
_render_summary_plot_catalog(list catalog, str output_format)
Render available summarize plot-series metadata.
Definition core.py:25091
float reference_scale(tuple dimension, dict scales)
Return the factor that turns a physical quantity of this dimension into solver units.
Definition core.py:9585
dict _read_checkpoint_options(str metadata_path)
Parse the deliberately small PETSc-options checkpoint manifest.
Definition core.py:1574
inputs_workflow(args)
Handle explicit workspace input management.
Definition core.py:26562
_mapping_value_with_aliases(dict mapping, *keys, default=None)
Return the first defined value from a mapping across alias keys.
Definition core.py:3309
"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:14374
format_flag_value(value)
Converts Python types to C-style command-line flag values.
Definition core.py:13458
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:2905
dict _build_case_overview(dict context)
Build a curated case.yml summary with useful derived quantities.
Definition core.py:22267
"dict | None" _parse_binary_petsc_line(str line)
Parse the PETSc build line a native executable reports.
Definition core.py:1868
bool _to_bool(value, str field_name)
Convert a YAML scalar/string to bool with a clear error message.
Definition core.py:8393
submit_staged_jobs(args)
Submit previously staged Slurm artifacts from an existing run/study directory.
Definition core.py:25768
choose_decomposition(int num_procs, list block_dims, "dict | None" solver_cfg, bool any_periodic)
Choose the MPI decomposition of a structured grid for a rank count.
Definition core.py:13843
int normalize_les_filter_width(value)
Maps LES grid-filter-width model names to the C -les_filter_width flag.
Definition core.py:15843
str _resolve_version_ref(str version)
Resolve a user-named version to a Git ref that exists in the source checkout.
Definition core.py:26802
tuple resolve_unsafe_paths_override(dict dirs, str monitor_path)
Resolve the unsafe-paths override, requiring a real YAML boolean.
Definition core.py:9776
float _checkpoint_time_for_series(str run_dir, int step, dict fallback, float time_scale=1.0)
Read the authoritative time of one visualization frame.
Definition core.py:5195
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:18190
dict normalize_post_spectra_config(dict post_cfg)
Validate and canonicalize the spectra block of post.yml.
Definition core.py:3770
dict normalize_post_reducers_config(dict post_cfg)
Validate and canonicalize the reducers block of post.yml.
Definition core.py:4263
"list[tuple[int, str]]" _block_family_paths(str log_dir, *str bases)
Return (block, path) for each per-block solver record, CSV preferred per block.
Definition core.py:24026
list normalize_particle_fields(fields_cfg)
Validate models.physics.particles.fields and lower each value to expressions.
Definition core.py:14533
append_turbulence_flags(dict models, list control_lines, dict scales=None)
Appends turbulence model flags from legacy or structured case.yml blocks.
Definition core.py:16136
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:5756
dict apply_run_executable_pinning(str run_dir, str revision, args, "str | None" workspace_root)
Pin, keep, or refuse the run's executables before anything records them.
Definition core.py:1240
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:17795
str run_initial_spectrum_generator(str field_path, str staged_grid, str spectrum_path, str case_dir, task_cfg=None)
Measure the shell-averaged spectrum of a staged initial condition.
Definition core.py:15249
dict append_grid_da_processor_layout(list control_lines, dict grid_cfg, int num_procs, "list | None" block_dims=None, "dict | None" solver_cfg=None, bool any_periodic=False, explicit=None, "dict | None" cluster_cfg=None)
Resolve the solver's MPI decomposition, report it, and append its flags.
Definition core.py:13974
str rebuild_command(str name, dict identity)
The command that rebuilds one native executable.
Definition core.py:2086
bool _post_requests_field_statistics(dict post_cfg)
Return whether the current post recipe derives accumulated field statistics.
Definition core.py:4706
dict _build_solver_overview(dict context)
Build a curated solver.yml summary with normalized selections.
Definition core.py:22360
list _merge_spectra_rows(str path, tuple fieldnames, set remeasured, list new_rows)
Combine newly measured spectra rows with those already written for other steps.
Definition core.py:4443
write_yaml_file(str filepath, dict data)
Write YAML with stable ordering for generated study artifacts.
Definition core.py:2381
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:4002
dict _parse_particle_snapshot_file(str filepath)
Parse sampled particle snapshots from a solver stream log.
Definition core.py:22629
source_workflow(args)
Fetch source history without silently changing the active code.
Definition core.py:26695
_read_json_if_exists(str filepath)
Read JSON when present, otherwise return None.
Definition core.py:22068
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:7445
dict resolve_scalar_transport(dict case_cfg, dict solver_cfg, bool warn=False)
Resolve the scalar-transport parameters from case.yml or their deprecated location.
Definition core.py:3662
dict resolve_resampled_velocity_params(dict params, prepared_blocks, dict scales, provider_context=None)
Validate resampled_velocity parameters and convert them to the provider contract.
Definition core.py:14592
str _command_to_string(list command_tokens)
Render a command list as a shell-safe display string.
Definition core.py:18607
float _to_float(value, str field_name)
Convert a YAML scalar to float with a clear error message.
Definition core.py:8366
None normalize_decomposition_args(args)
Resolve -n and the optional --ni/--nj/--nk of picurv run into one rank count.
Definition core.py:13807
float input_reference_scale(tuple path, dict scales)
Return the reference scale of one recorded input, for converting a whole payload.
Definition core.py:9599
dict resolve_initial_condition_config(dict ic, prepared_blocks, dict scales, provider_context=None)
Resolve legacy and structured initial-condition YAML into one launcher contract.
Definition core.py:14742
int payload_petsc_index_bytes(str path)
PetscInt width of a PETSc payload: as its checkpoint recorded it, else the driven solver's.
Definition core.py:15149
str _stable_mapping_sha256(payload)
Hash a JSON-compatible value with deterministic serialization.
Definition core.py:19520
dict _require_summary_config(dict context, str name)
Return one explicitly requested copied config or fail with a structured error.
Definition core.py:22195
append_les_diagnostics_flags("dict | None" diagnostics, list control_lines)
Append the LES diagnostic log flags resolved by resolve_les_diagnostics().
Definition core.py:16122
str parse_slurm_job_id(str sbatch_output)
Extract numeric job id from standard sbatch output.
Definition core.py:12385
str resolve_command_log_path(str run_dir, str log_filename)
Resolve a command log filename relative to the run directory.
Definition core.py:17410
"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:23961
str _format_study_group_value(value)
Format a secondary study parameter compactly for a legend.
Definition core.py:18407
int normalize_flow_direction_token(str value)
Maps a face-token flow direction string to the C FlowDirection enum integer.
Definition core.py:15439
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:18466
str resolve_runtime_executable(str executable_name)
Resolve solver/post executable path, preferring local sibling binaries.
Definition core.py:1835
dict picurv_build()
The conductor's own build identity, resolved on first use and kept for the process.
Definition core.py:291
list _asset_payload_files(str build_root, str kind)
Enumerate canonical files belonging to one asset kind in a build tree.
Definition core.py:19733
list _collect_summary_plot_records(dict context)
Collect append-ordered numeric records from summarize-supported scalar logs.
Definition core.py:24540
dict build_post_execution_plan(str run_dir, str run_id, dict case_cfg, dict monitor_cfg, dict post_cfg, bool recompute=False, bool allow_source_frontier_scan=True, "str | None" postprocessor_sha=None)
Decide which steps of a recipe's window one invocation processes.
Definition core.py:6169
str generate_post_recipe_file(str run_dir, str run_id, dict post_cfg, dict source_files, monitor_cfg=None, "dict | None" decomposition=None)
Generates a key=value config file (post.run) for the C post-processor.
Definition core.py:17299
"list[tuple[int, str, list]]" _poisson_history_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Poisson iteration history.
Definition core.py:24346
_list_summary_tables(dict context, str output_format)
Print the summarize --table names whose CSV this run has, with row counts.
Definition core.py:25153
"str | None" _log_family_path(str log_dir, str base)
Return a solver record's CSV, or the text log an older run wrote instead.
Definition core.py:24012
sync_case_config_command(args)
Refresh template-managed config/docs files in a case directory.
Definition core.py:26919
dict enforce_workspace_version(str workspace_root)
Enforce an optional workspace PICurv version requirement.
Definition core.py:522
str resolve_recipe_spectra_output_dir(dict post_cfg, monitor_cfg=None)
Resolve the canonical spectra directory for one versioned recipe.
Definition core.py:4487
build_project(args)
Implements the 'build' command.
Definition core.py:27018
dict _decomposition_layout(layout)
Express an (ni, nj, nk) tuple as the da_processors mapping the layout checks read.
Definition core.py:13833
validate_eulerian_checkpoint(str source_dir, int step, dict monitor_cfg)
Validate the mandatory Eulerian field set required by ReadSimulationFields().
Definition core.py:6611
int normalize_field_init_mode(str value)
Maps canonical field init mode names to C enum/int codes (-finit).
Definition core.py:14352
require_project_root(str candidate, str purpose)
Validate that a source repo root was resolved and is structurally valid.
Definition core.py:2885
"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:12119
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:6708
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:801
fail_cli_usage(str message, str hint=None)
Emit a structured CLI usage error and exit with code 2.
Definition core.py:2256
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:20174
dict _diagnostic_info(value)
Validate PETSc info logging configuration.
Definition core.py:12667
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:7155
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:20128
_drop_imported_package(str package_name)
Remove a failed/partial import package tree from sys.modules.
Definition core.py:121
_nearest_step("set[int]" steps, int target)
Return the complete source step nearest to a target step.
Definition core.py:5700
_print_config_header(str title, "str | None" subtitle=None)
Print a strong dashboard-style configuration summary header.
Definition core.py:23550
bool petsc_library_mismatch("dict | None" petsc, "str | None" library)
Whether the library the loader resolves lies outside the PETSc the binary was built against.
Definition core.py:1938
str _asset_file_sha256(str path)
Hash an asset source or payload without loading it into memory.
Definition core.py:19507
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:15395
dict resolve_fluid_scaling(dict case_cfg)
Resolve the shared physical and nondimensional fluid scaling contract.
Definition core.py:14479
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:7320
str _sanitize_error_field(value)
Normalize error fields into a single-line string.
Definition core.py:2218
dict _get_submission_stage_metadata(dict target_context, str stage_name)
Return stored metadata for one staged submission target.
Definition core.py:25513
dict run_post_spectra_stage(str run_dir, dict post_cfg, dict monitor_cfg, str source_dir, steps, bool quiet=False, "dict | None" plan=None, bool recompute=False)
Measure spectra for every requested task across a window of committed steps.
Definition core.py:4057
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:11071
dict validate_newton_krylov_config(dict cfg)
Validate and normalize the structured Newton–Krylov solver block.
Definition core.py:14026
bool has_explicit_monitor_whitelist(dict monitor_cfg)
Return True when logging.enabled_functions contains at least one entry.
Definition core.py:12570
dict read_binary_build_identity(str executable_path)
Read the build identity a native executable was compiled with.
Definition core.py:1998
tuple staged_control_directories(str control_path)
Read run-owned directory values from a staged control file.
Definition core.py:25638
set _les_periodic_axes(dict case_cfg)
Reports which logical axes a case declares periodic on both faces.
Definition core.py:8435
"tuple[str | None, list]" _continuity_rows(str log_dir)
Return the continuity record's path and its (segment, row) pairs.
Definition core.py:24043
bool is_project_root(str candidate)
Return True when a directory looks like the PICurv source repository root.
Definition core.py:2488
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:12159
None finalize_post_outputs(str run_dir, dict post_cfg, stages)
Run every serial step that reads a successful field stage's output.
Definition core.py:5424
int normalize_les_test_filter(value)
Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
Definition core.py:15814
"tuple[dict, list[int]]" _parse_continuity_metrics_log(str log_dir)
Read the continuity record into rows by step (one per block) plus observed order.
Definition core.py:22440
str normalize_les_averaging_directions(value)
Maps a list of homogeneous logical directions to the C flag's string form.
Definition core.py:15937
tuple unused_asset_kinds(dict case_cfg, dict solver_cfg)
Asset kinds a run with these configurations would build but never read.
Definition core.py:15617
validate_cluster_config(dict cluster_cfg, str cluster_path)
Validate Slurm scheduler configuration from cluster.yml.
Definition core.py:11423
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:17449
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:2789
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:12763
list_source_binaries(str source_project_root)
List binary artifacts currently available in the source repo bin directory.
Definition core.py:2998
float ic_payload_divisor(dict resolved_ic, str case_dir)
Return the factor a file-backed initial condition is divided by when staged.
Definition core.py:14429
restore_continuation_records(str run_dir, dict saved, str revision)
Undo a refused continuation's staging: restore its records, drop its revision.
Definition core.py:1055
bool _post_needs_particle_source(dict post_cfg)
Return whether the current post recipe requires particle source files to be present.
Definition core.py:5446
_print_config_group(str title, list rows)
Print an aligned configuration-summary field group.
Definition core.py:23563
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:2849
_iter_nonempty_noncomment_lines(file_obj)
Yield (lineno, stripped_line) for non-empty, non-comment lines.
Definition core.py:7132
str normalize_eulerian_field_source(str value)
Normalizes the Eulerian field source selector to the C-side canonical string.
Definition core.py:15575
dict load_active_run_configuration(str run_dir)
Load the active immutable configuration revision for a run.
Definition core.py:1082
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:26287
str case_run_label(dict case_cfg, str case_path)
Resolve the stable human-facing portion of a generated run identifier.
Definition core.py:783
"list | None" resolve_grid_node_dims(dict case_cfg, str case_path, "str | None" staged_grid=None)
Per-block (IM, JM, KM) node counts the solver will read, without building the grid.
Definition core.py:13673
str describe_decomposition(dict resolved, int num_procs)
One line stating the decomposition a stage launches with.
Definition core.py:13955
to_solver_units(value, tuple path, dict scales)
Convert one physical configuration value, or a list of them, to solver units.
Definition core.py:9634
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:1378
_diagnostic_bool_or_path(value, str key)
Validate a diagnostics value that can be false, true, or a path/viewer string.
Definition core.py:12696
int _lineage_reset_index(list segments, str family)
Return the first segment belonging to one continuous artifact family.
Definition core.py:5263
dict plan_run_assets(dict case_cfg, str case_path, skip_kinds=())
Plan reuse/build/runtime actions for all configured run providers.
Definition core.py:20276
dict resolve_profiling_config(dict monitor_cfg)
Resolve profiling reporting config from monitor.yml.
Definition core.py:12585
_render_summary_table(dict context, str name, last_n, columns, str output_format)
Print the last rows of one of the run's CSV records as aligned columns (or JSON).
Definition core.py:25183
list build_spectra_follow_command(str run_dir, str post_path, dict post_cfg, bool recompute=False)
Build the batch-script step that measures spectra after the field stage.
Definition core.py:11857
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:8959
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:19902
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:19949
dict get_post_lock_paths(str run_dir, str recipe_id=None)
Return lock-wrapper related paths for a run directory.
Definition core.py:5018
"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:5471
str _classify_error_code(str message)
Map existing validation/error messages to the standardized code set.
Definition core.py:2314
subprocess.CompletedProcess _git_source_command(list arguments)
Run a checked Git command against the active PICurv source checkout.
Definition core.py:26680
list physical_units_transition_notices(dict case_cfg, dict solver_cfg, dict monitor_cfg)
Name the inputs whose meaning changed when every input became physical.
Definition core.py:10101
"tuple[str | None, list[str]]" normalize_cluster_launcher(dict execution)
Canonicalize cluster launcher config into executable token plus argv-style flags.
Definition core.py:12106
dict _compute_particle_snapshot_delta("list[dict]" current_rows, "list[dict]" previous_rows)
Compute sampled deltas between two particle snapshot samples.
Definition core.py:22693
"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:2440
_checkpoint_commit(str source_dir, int step)
Identify one committed checkpoint by its commit marker.
Definition core.py:5854
dict write_scaled_petsc_vec_binary(str source, str destination, float divisor)
Copy a PETSc binary Vec, dividing every scalar by one factor.
Definition core.py:15202
"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:22607
list petsc_library_path_lines("str | None" library_dir)
Shell lines putting one PETSc's lib/ first on LD_LIBRARY_PATH, after module setup.
Definition core.py:11902
None validate_programmatic_bridge_grid_settings(dict raw_settings)
Validate the scalar programmatic grid settings the bridge PICGRID is built from.
Definition core.py:13496
str _summary_field_label(str field)
Return the report-facing label for one logged scalar field.
Definition core.py:23952
_lookup_allowed_schema_keys(dict schema, tuple path)
Return allowed keys for a path, honoring '*' dynamic mapping entries.
Definition core.py:8913
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:22770
int normalize_wall_function_model(value)
Maps wall-function model selectors to the C -wallfunction flag.
Definition core.py:15968
dict _build_monitor_overview(dict context)
Build a curated monitor.yml summary with resolved defaults.
Definition core.py:22397
persist_post_resume_state(str run_dir, dict plan, steps=None, spectra=None)
Write a recipe's state: the window last planned and its per-step output records.
Definition core.py:5825
dict _provider_source_fingerprints(value, str case_path, str key="")
Hash every existing file explicitly referenced by an asset provider.
Definition core.py:19530
bool _ic_has_inlet(prepared_blocks)
Return True if any prepared BC block contains an INLET face.
Definition core.py:15458
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:7671
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:2413
str compute_physical_case_identity(dict case_cfg)
Compute the hidden identity used to guard in-place continuation.
Definition core.py:6502
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:19208
"tuple[float, float] | None" _study_linear_y_limits(list values)
Build padded linear limits that include zero for non-negative metrics.
Definition core.py:18479
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:3467
_print_validation_errors(list errors)
Prints validation errors and exits.
Definition core.py:12471
detect_last_checkpoint_step(str output_dir)
Scan output directory for the highest step number available.
Definition core.py:6624
"list[tuple[int, str, list]]" _picard_history_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Picard momentum history.
Definition core.py:24178
_invoke_plot_gen(dict request)
Invoke standalone plot.gen with one normalized request over stdin.
Definition core.py:25226
int _post_window_derived_field_count(dict window_cfg, list outputs, dict eligible=None)
Count the derived fields one window would produce for a set of outputs.
Definition core.py:4668
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:11789
"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:5721
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:13618
_find_stored_asset(str workspace_root, dict provider)
Find a published object built from exactly this provider's inputs.
Definition core.py:20237
dict flatten_study_parameters(dict parameters)
Flatten grouped study overrides into scalar dotted-path columns.
Definition core.py:11759
str format_command_for_display(list command)
Render a shell-safe command string for console and log output.
Definition core.py:17401
_diagnostic_bool_or_all(value, str key)
Validate a diagnostics value that can be false, true, or "all".
Definition core.py:12722
"dict | None" resolve_walltime_guard_policy("dict | None" cluster_cfg)
Resolve the effective Slurm walltime-guard policy for generated solver jobs.
Definition core.py:1785
dict resolve_solution_monitoring_flags(dict monitor_cfg)
Translate solution-monitoring YAML into the existing C convergence flags.
Definition core.py:13242
dict ic_provider_params_to_solver_units(str generator, dict params, dict scales)
Convert a provider's validated physical parameters to solver units.
Definition core.py:14567
auto_identify_run_inputs(str config_dir)
Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
Definition core.py:17762
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:25407
_typed_csv_value(str text)
Return a CSV cell as a float (written with a point or exponent), an int, or the text.
Definition core.py:24433
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:716
bool _post_requests_statistics(dict post_cfg)
Return whether the current post recipe expects statistics CSV artifacts.
Definition core.py:5437
str _humanize_plot_identifier(str value)
Convert one machine-oriented identifier into a readable plot label.
Definition core.py:23936
sweep_continue_workflow(args)
Continue a partially-completed Slurm parameter sweep study.
Definition core.py:21713
bool _is_summary_plot_continuation_marker(str line)
Return whether a log line starts a new continuation segment.
Definition core.py:24531
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:3325
str generate_header(str run_id, dict source_files)
Creates a standard header block for all generated files.
Definition core.py:12490
dict materialize_run_assets(str run_dir, dict case_cfg, str case_path, bool require_precomputed=False, bool fetch_missing=False, skip_kinds=())
Resolve/build workspace assets and write the exact run input lock.
Definition core.py:20368
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:7967
str _study_metric_label(dict study_cfg, str metric)
Resolve an optional configured metric label or humanize its name.
Definition core.py:18305
"int | None" requested_petsc_index_bytes(args=None)
The index width a command was told to use: --petsc-index-bits, else PICURV_PETSC_INDEX_BITS.
Definition core.py:15001
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:3372
check_restart_continuation(str solver_exe, list solver_args, str run_dir, str config_search_anchor=None)
Ask the simulator whether a staged restart's carried statistics windows can continue.
Definition core.py:12269
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:3982
"list[tuple[int, str, list]]" _newton_history_rows(str log_dir)
Return (block, path, [(segment, row)]) for each Newton iteration history.
Definition core.py:24255
str normalize_analytical_type(str value)
Normalizes the analytical solution selector to the C-side canonical string.
Definition core.py:15627
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:18616
sync_case_template_files(str case_dir, str template_dir, bool overwrite=False, bool prune=False, managed_rel_paths=None, str template_name=None)
Sync template files into a case directory, preserving modified files unless overwrite is requested.
Definition core.py:3036
bool _statistics_subsystem_available(dict case_cfg, requirement)
Report whether the subsystem a statistics field depends on is active.
Definition core.py:12897
load_generator_module(str script_name, str module_name)
Load one of the extensionless scripts under generators/ as a module.
Definition core.py:14503
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:24824
"str | None" _git_output(*arguments)
Run one git command in the source checkout.
Definition core.py:224
str format_picgrid_coordinate(float value)
Format a coordinate with round-trip-safe binary64 precision.
Definition core.py:7146
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:7426
dict normalize_post_recipe_signature(dict recipe_cfg)
Normalize post recipe settings into a stable signature mapping.
Definition core.py:4962
"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:5557
list resolve_run_lineage(str run_dir, str scope="lineage")
Return root-to-leaf run segments, for a ParaView collection or a reducer's metrics.
Definition core.py:5137
normalize_metric_spec(metric)
Normalize study metric definitions to a common dictionary form.
Definition core.py:18153
dict _normalize_prescribed_flow_source(source, str field_name)
Validate the structured source block for prescribed_flow BCs.
Definition core.py:8156
"dict | None" _decomposition_record("dict | None" resolved)
The JSON-serializable record of a resolved decomposition for run metadata.
Definition core.py:13943
get_post_statistics_task_tokens(dict post_cfg)
Return normalized statistics pipeline tokens that will be written into post.run.
Definition core.py:3428
"dict[int, str]" _scan_post_vtk_steps(str prefix_path, str extension)
Collect the VTK files named with a prefix, step suffix, and extension.
Definition core.py:5537
None validate_continue_case_identity(str run_dir, dict case_cfg)
Reject in-place continuation when the physical case has changed.
Definition core.py:6531
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:20041
None run_post_finalize_locked(str run_dir, str recipe_fingerprint, list finalize_command)
Refresh a collection-only result under the ordinary post writer lock.
Definition core.py:6139
float _to_finite_float(value, str field_name)
Convert a non-boolean YAML scalar to a finite float.
Definition core.py:8379
infer_plot_x_axis(dict study_cfg, list rows)
Infer x-axis key/values for study plots.
Definition core.py:18394
str _read_release_version()
Read the single release version shared by every PICurv executable.
Definition core.py:208
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:16360
"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:5740
_relative_to_workspace(str path, str workspace_root)
Return a portable workspace-relative path when possible.
Definition core.py:974
str _format_summary_float(value, str spec=".6e", str missing="n/a")
Format optional numeric values for summary text output.
Definition core.py:23033
str run_executable_path("str | None" run_dir, str name)
Resolve the executable a run launches: its pinned copy, else the installation's.
Definition core.py:1125
str _workspace_asset_set_name(str workspace_root, str case_path)
Return a collision-free, readable mutable asset-set name.
Definition core.py:20114
find_project_root_upwards(str start_path)
Search upward from an anchor and return the first matching project root.
Definition core.py:2521
"list[tuple]" collect_post_completion_families(str run_dir, dict post_cfg, monitor_cfg=None)
Collect the output files each output family of the current recipe holds per step.
Definition core.py:5580
dict normalize_solution_monitoring_config(dict monitor_cfg)
Validate and canonicalize physical-solution convergence monitoring.
Definition core.py:13187
dict _normalize_field_slice_source(source, str field_name)
Validate a prescribed_flow field_slice source block.
Definition core.py:7488
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:8547
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:12300
generate_study_plots(dict study_cfg, str metrics_csv, str plots_dir)
Generate metric-vs-parameter plots for completed studies.
Definition core.py:18494
dict _run_component_states(str run_dir, dict stages_requested)
Report each run component's home, retention class, and lifecycle state.
Definition core.py:1333
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:3400
dict _get_git_head_state(str run_dir)
Capture the current git HEAD branch name and commit hash.
Definition core.py:17532
tuple input_quantity(tuple path)
Return the (dimension, conversion site) recorded for one configuration input.
Definition core.py:9571
render_run_dry_plan(dict plan, str output_format="text")
Render dry-run plan in human or JSON format.
Definition core.py:19245
list resolve_traced_particle_ids(dict monitor_cfg)
Return the sorted, distinct particle IDs of logging.trace_particles.
Definition core.py:16329
list resolve_field_statistics_flags(dict monitor_cfg, dict case_cfg=None)
Serialize field-statistics configuration into control-file option lines.
Definition core.py:13123
list resolve_conductor_entry_point()
Resolve how to invoke this conductor again from a batch script.
Definition core.py:11840
dict build_run_dry_plan(args)
Build a no-write execution plan for run --dry-run.
Definition core.py:18631
_file_sha256(str path)
Content digest of one file, or None when it cannot be read.
Definition core.py:845
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:2679
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:18422
dict _normalize_field_slice_selector(slice_cfg, str field_name)
Validate the field_slice slice selector.
Definition core.py:7550
_recorded_run_root(str run_dir)
Return the location a run's generated files refer to.
Definition core.py:1494
"dict | None" _read_binary_build_stamp(str executable_path)
Read the build identity stamped into an executable's bytes, without running it.
Definition core.py:1953
_render_solver_summary_text(dict summary)
Render the solver summary as a glanceable numerical-method dashboard.
Definition core.py:23694
append_passthrough_flags(list control_lines, dict options)
Appends raw CLI flags to the control list from a {flag: value} dict.
Definition core.py:16210
float _summary_source_mtime(paths)
Return the newest modification time among one or more summary sources.
Definition core.py:23046
str initialize_workspace_root(str workspace_root, str template_name)
Create the workspace skeleton and its identity file at one root.
Definition core.py:667
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:9801
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:4759
list _collect_profile_plot_records(dict context)
Collect the profile histories the profile_series reducer wrote.
Definition core.py:24749
find_case_origin_metadata_file(str case_dir_hint=None)
Find the nearest case-origin metadata file from known runtime anchors.
Definition core.py:2556
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:7795
_prune_incompatible_python_site_paths(paths)
Remove site-package paths for a different Python major/minor version.
Definition core.py:101
quantity_to_solver_units(value, tuple quantity, dict scales, str label)
Convert a physical value, or a list of them, by one recorded quantity entry.
Definition core.py:9615
_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:26262
tuple preflight_staged_run_directories(str root_dir)
Re-check run-directory safety against an already-staged run or study.
Definition core.py:25709
_deep_set(dict container, str dotted_path, value)
Set nested dictionary value, creating intermediate maps when needed.
Definition core.py:11718
cancel_run_jobs(args)
Cancel Slurm-submitted jobs for an existing run directory.
Definition core.py:26083
str normalize_extension(str ext)
Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
Definition core.py:11830
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:1605
validate_wall_model_pairing(dict case_cfg, les_cfg, wall_cfg, str case_path, list errors, list warnings)
Rejects wall-model selections that no turbulence treatment can support.
Definition core.py:8468
int normalize_interpolation_method(str value)
Maps interpolation method names to C enum/int codes (-interpolation_method).
Definition core.py:15735
dict capture_continuation_records(str run_dir, str restart_dir)
Save what a continuation's staging changes, so a refusal can undo it.
Definition core.py:1036
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:19149
bool resolve_enabled_flag(dict cfg, str path, bool default=True)
Resolves a structured enabled flag and rejects non-boolean values.
Definition core.py:15996
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:16976
int select_run_petsc_index_bytes(args, dict case_cfg, str case_path, "dict | None" monitor_cfg, "str | None" restart_source_dir=None)
Choose and select the PetscInt width a run's solve stage needs.
Definition core.py:15085
dict _toolchain_identity()
Best-effort record of the PETSc, MPI, and compiler in the shell staging the run.
Definition core.py:903
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:22945
dict _build_summary_context(str run_dir)
Resolve run-local config and artifact paths for summarize.
Definition core.py:22128
dict effective_run_directories(dict configured)
Fill in defaults for run-owned directories that were not configured.
Definition core.py:10015
"tuple[dict, list[int]]" _parse_particle_metrics_log(str log_dir, str metrics_dir)
Read the per-step particle record into rows by step plus observed order.
Definition core.py:22456
bool resolve_executable_pinning(args, "str | None" workspace_root)
Decide whether staging pins the installation's executables into the run.
Definition core.py:1218
extract_metric_from_log(str case_dir, dict spec)
Extract a scalar metric from a log file using regex.
Definition core.py:18122
load_case_origin_metadata(str case_dir_hint=None)
Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
Definition core.py:2583
str normalized_run_directory(str value)
Normalized, comparable form of a contained run directory value.
Definition core.py:9755
str _extract_key_path(str message)
Best-effort key-path extraction from free-form validation messages.
Definition core.py:2293
_iter_post_steps(int start_step, int end_step, int step_interval)
Yield configured post-processing steps inclusively.
Definition core.py:5456
dict resolve_solver_monitoring_flags(dict monitor_cfg)
Resolve human-readable solver monitoring YAML to raw control flags.
Definition core.py:16253
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:4498
str post_spectra_task_basename(dict task_cfg, str output_prefix)
Build the file basename one normalized spectra task writes.
Definition core.py:4521
dict normalize_post_paraview_series_config(dict post_cfg)
Normalize the optional ParaView collection policy in a post recipe.
Definition core.py:5072
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:8228
_workspace_requested_version(str workspace_root)
Resolve an exact version requested by an initialized workspace.
Definition core.py:26707
str _diagnostic_default_file(str run_dir, str filename)
Return an absolute run-local diagnostics file path.
Definition core.py:12736
"set[int]" _scan_committed_checkpoint_steps(str source_dir, bool require_particles=False)
Return only fully validated, committed checkpoint steps.
Definition core.py:1701
_rewrite_workspace_path_values(value, dict replacements)
Rewrite copied template path scalars to workspace-root-relative homes.
Definition core.py:26245
require_numpy()
Import NumPy only for commands that need numeric reductions.
Definition core.py:132
dict resolve_sweep_stage_executables(list run_dirs)
Choose the executables an array stage launches for every member of a study.
Definition core.py:21338
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:13261
list _flatten_summary_mapping(dict mapping, str prefix="")
Flatten nested summary mappings into readable dotted field rows.
Definition core.py:23578
None enforce_run_directory_structure(str run_dir)
Apply validate_run_directory_structure() as a refusal at run time.
Definition core.py:761
versions_workflow(args)
List or activate a release using the existing source/build owners.
Definition core.py:26728
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:12325
bool is_generated_ic_provider(dict resolved_ic)
Return whether a resolved IC is backed by a registered file generator.
Definition core.py:14470
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:6682
str _study_parameter_label(str key)
Return a concise report label for a study parameter path.
Definition core.py:18288
"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:15673
int parse_slurm_time_limit_to_seconds(str time_text)
Parse a Slurm time-limit string into total seconds.
Definition core.py:1728
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:7036
None ensure_run_layout(str run_dir)
Materialize the uniform, cheap directory skeleton for one run.
Definition core.py:696
int get_cluster_total_tasks(dict cluster_cfg)
Return cluster total tasks.
Definition core.py:11821
dict read_monitor_from_run(str run_dir)
Read the monitor.yml from a run directory's config/ subdirectory.
Definition core.py:6696
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:12922
"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:23063
dict pin_run_executables(str run_dir, str revision)
Copy the installation's executables into a run's configuration revision.
Definition core.py:1144
bool _diagnostic_bool(value, str key)
Validate a diagnostics boolean value.
Definition core.py:12710
"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:15646
list build_post_finalize_command(str run_dir, str archived_post_path, dict post_cfg, post_stages=POST_STAGE_NAMES)
Build the serial post-success command for staged execution.
Definition core.py:5402
"list | None" _numeric_study_column(list rows, str key)
Parse one complete, finite numeric study-table column.
Definition core.py:18323
print_case_source_status(dict status)
Render human-readable source/case drift details.
Definition core.py:3203
None _check_file_ic_matches_grid(dict payload, str staged_grid, dict resolved_ic)
Refuse a mode: file payload sized for another grid, and say what imports one.
Definition core.py:15367
dict read_yaml_file(str filepath)
Safely reads a YAML file and returns its content.
Definition core.py:2353
precompute_workflow(args)
Resolve, preflight, and atomically publish reusable workspace assets.
Definition core.py:20487
run_workflow(args)
Main orchestrator for the 'run' command (local and Slurm modes).
Definition core.py:20520
bool initial_condition_is_authoritative(dict case_cfg, dict solver_cfg)
Whether the configured initial condition is the field the solver starts from.
Definition core.py:15600
dict resolve_runtime_execution_context(dict runtime_execution_cfg, str context)
Resolve default plus context-specific execution overrides.
Definition core.py:2751
int resolve_post_owned_start(str run_dir, dict post_cfg, int requested_start, int step_interval)
Intersect a recipe's logical cadence with this run's branch.
Definition core.py:5492
pull_source_repo(args)
Refresh source branches in the repository resolved from a case directory.
Definition core.py:26972
"tuple[dict, dict]" _read_post_records(str state_path)
Read the per-step output records a recipe's state holds.
Definition core.py:5812
"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:4926
str _resolve_lineage_parent_run(str child_run_dir, dict lineage)
Resolve and identity-check one recorded lineage parent.
Definition core.py:5094
list _collect_spectra_plot_records(dict context)
Collect the per-step scalar histories written by the spectra post stage.
Definition core.py:24703
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:3903
str compute_post_recipe_id(dict post_cfg)
Compute a stable human-readable identity for one post recipe.
Definition core.py:4887
resolve_template_directory(str source_project_root, str template_name)
Resolve an example template directory inside the source repository.
Definition core.py:2925
Head of a generic C-style linked list.
Definition variables.h:518