6@brief A comprehensive conductor script for the PICurv simulation platform.
8This script acts as the central user interface for running simulations,
9managing configurations, and orchestrating the entire end-to-end workflow.
10It translates user-friendly YAML files into C-solver compatible control files,
11supports full multi-block configurations, and provides live log streaming.
12It features intelligent, content-based config file discovery and robustly
13manages data I/O paths for the post-processor. It also supports Slurm job
14generation/submission and parameter sweeps via job arrays.
33from datetime
import datetime
38_MATPLOTLIB_PYPLOT =
None
43 @brief Module-like proxy that preserves `picurv.np` without eager import.
48 @brief Resolve a NumPy attribute on first use.
49 @param[in] name NumPy attribute name.
50 @return Requested NumPy attribute.
60 @brief Remove site-package paths for a different Python major/minor version.
61 @param[in] paths Candidate sys.path entries.
62 @return Filtered path list.
64 current = (sys.version_info[0], sys.version_info[1])
65 pattern = re.compile(
r"python(?:-)?(\d+)\.(\d+)", re.IGNORECASE)
69 match = pattern.search(text)
71 path_version = (int(match.group(1)), int(match.group(2)))
72 if path_version != current
and (
"site-packages" in text
or "dist-packages" in text):
80 @brief Remove a failed/partial import package tree from sys.modules.
81 @param[in] package_name Top-level package name.
83 prefix = package_name +
"."
84 for module_name
in list(sys.modules):
85 if module_name == package_name
or module_name.startswith(prefix):
86 sys.modules.pop(module_name,
None)
91 @brief Import NumPy only for commands that need numeric reductions.
92 @return Imported NumPy module.
95 if _NUMPY_MODULE
is not None:
99 except Exception
as exc:
101 original_path =
list(sys.path)
106 except Exception
as retry_exc:
108 "NumPy is required for this operation, but no compatible NumPy "
109 "could be imported for this Python interpreter. PICurv ignored "
110 "site-packages paths for other Python versions and retried. "
111 f
"First error: {first_error}. Retry error: {retry_exc}"
114 sys.path = original_path
115 _NUMPY_MODULE = numpy
121 @brief Import matplotlib.pyplot lazily for study plot generation.
122 @return matplotlib.pyplot when available, otherwise None.
124 global _MATPLOTLIB_PYPLOT
125 if _MATPLOTLIB_PYPLOT
is not None:
126 return _MATPLOTLIB_PYPLOT
127 original_path =
list(sys.path)
129 import matplotlib.pyplot
as pyplot
134 import matplotlib.pyplot
as pyplot
138 sys.path = original_path
139 _MATPLOTLIB_PYPLOT = pyplot
140 return _MATPLOTLIB_PYPLOT
145PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))
146PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)
147INVOKED_SCRIPT_DIR = os.environ.get(
148 "_PICURV_INVOKED_SCRIPT_DIR",
151SCRIPT_PATH = os.environ.get(
152 "_PICURV_SCRIPT_PATH",
155PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)
156GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT,
"generators")
157if os.path.basename(SCRIPT_PATH) ==
"bin":
158 DEFAULT_BIN_DIR = SCRIPT_PATH
160 DEFAULT_BIN_DIR = os.path.join(PROJECT_ROOT,
"bin")
162PICURV_VERSION =
"0.1.0"
163CASE_ORIGIN_METADATA_FILENAME =
".picurv-origin.json"
164RUNTIME_EXECUTION_CONFIG_FILENAME =
".picurv-execution.yml"
165LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME =
".picurv-local.yml"
166RUNTIME_EXECUTION_EXAMPLE_FILENAME =
"execution.example.yml"
167RUNTIME_EXECUTION_CONFIG_FILENAMES = (
168 RUNTIME_EXECUTION_CONFIG_FILENAME,
169 LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME,
172DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE =
"""# Optional shared runtime launcher overrides.
173# This file is safe to leave unchanged on ordinary local machines.
174# Edit it only when your site needs custom MPI launcher tokens.
177# - local/login-node runs: local_execution -> default_execution -> built-in mpiexec
178# - generated cluster jobs: cluster.yml.execution -> cluster_execution -> default_execution -> built-in srun
193CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT =
"my_project_account"
194CLUSTER_TEMPLATE_PLACEHOLDER_MAIL =
"user@example.edu"
196DEFAULT_WALLTIME_GUARD_POLICY = {
201 "estimator_alpha": 0.35,
203WALLTIME_GUARD_ENV_JOB_START_EPOCH =
"PICURV_JOB_START_EPOCH"
204WALLTIME_GUARD_ENV_LIMIT_SECONDS =
"PICURV_WALLTIME_LIMIT_SECONDS"
205POST_RESUME_STATE_FILENAME =
"post.resume.json"
206POST_LOCK_FILENAME =
"post.lock"
207POST_LOCK_METADATA_FILENAME =
"post.lock.json"
208POST_LOCK_WRAPPER_FILENAME =
"post_lock_wrapper.py"
209POST_RESUME_SCHEMA_VERSION = 1
210POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {
"startTime",
"endTime"}
211POST_REQUIRED_EULERIAN_SOURCE_BASENAMES = (
"ufield",
"vfield",
"pfield",
"nvfield")
216 @brief Parse a Slurm time-limit string into total seconds.
217 @param[in] time_text Argument passed to `parse_slurm_time_limit_to_seconds()`.
218 @return Value returned by `parse_slurm_time_limit_to_seconds()`.
220 text = str(time_text).strip()
222 raise ValueError(
"time limit cannot be empty")
227 day_text, clock_text = text.split(
"-", 1)
228 if not day_text.isdigit():
229 raise ValueError(f
"invalid day field '{day_text}'")
232 raise ValueError(
"missing time portion after day field")
234 parts = clock_text.split(
":")
236 raise ValueError(f
"unsupported time format '{time_text}'")
237 if any(part ==
"" for part
in parts):
238 raise ValueError(f
"malformed time field '{time_text}'")
239 if any(
not part.isdigit()
for part
in parts):
240 raise ValueError(f
"non-numeric time field '{time_text}'")
242 nums = [int(part)
for part
in parts]
245 hours, minutes, seconds = nums[0], 0, 0
247 hours, minutes = nums
250 hours, minutes, seconds = nums
253 hours, minutes, seconds = 0, nums[0], 0
256 minutes, seconds = nums
258 hours, minutes, seconds = nums
260 if minutes >= 60
or seconds >= 60:
261 raise ValueError(f
"minutes and seconds must be < 60 in '{time_text}'")
262 if days == 0
and len(nums) == 3
and hours < 0:
263 raise ValueError(f
"hours must be non-negative in '{time_text}'")
265 total_seconds = (((days * 24) + hours) * 60 + minutes) * 60 + seconds
266 if total_seconds <= 0:
267 raise ValueError(
"time limit must be positive")
273 @brief Resolve the effective Slurm walltime-guard policy for generated solver jobs.
274 @param[in] cluster_cfg Argument passed to `resolve_walltime_guard_policy()`.
275 @return Value returned by `resolve_walltime_guard_policy()`.
277 if not isinstance(cluster_cfg, dict):
280 scheduler = cluster_cfg.get(
"scheduler", {})
or {}
281 if str(scheduler.get(
"type",
"slurm")).lower() !=
"slurm":
284 execution = cluster_cfg.get(
"execution", {})
or {}
285 guard_cfg = execution.get(
"walltime_guard")
286 if guard_cfg
is None:
288 elif not isinstance(guard_cfg, dict):
289 raise ValueError(
"execution.walltime_guard must be a mapping when provided")
291 policy = copy.deepcopy(DEFAULT_WALLTIME_GUARD_POLICY)
292 policy.update(guard_cfg)
293 policy[
"enabled"] = bool(policy[
"enabled"])
294 policy[
"warmup_steps"] = int(policy[
"warmup_steps"])
295 policy[
"multiplier"] = float(policy[
"multiplier"])
296 policy[
"min_seconds"] = float(policy[
"min_seconds"])
297 policy[
"estimator_alpha"] = float(policy[
"estimator_alpha"])
303 @brief Build shell-evaluated environment exports for the runtime walltime guard.
304 @param[in] cluster_cfg Argument passed to `build_walltime_guard_exports()`.
305 @return Value returned by `build_walltime_guard_exports()`.
308 if not policy
or not policy.get(
"enabled",
False):
312 WALLTIME_GUARD_ENV_JOB_START_EPOCH:
"$(date +%s)",
313 WALLTIME_GUARD_ENV_LIMIT_SECONDS: str(walltime_limit_seconds),
318 @brief Resolve solver/post executable path, preferring local sibling binaries.
319 @param[in] executable_name Argument passed to `resolve_runtime_executable()`.
320 @return Value returned by `resolve_runtime_executable()`.
322 local_candidate = os.path.join(INVOKED_SCRIPT_DIR, executable_name)
323 if os.path.isfile(local_candidate):
324 return os.path.abspath(local_candidate)
325 return os.path.join(DEFAULT_BIN_DIR, executable_name)
328ERROR_CODE_CLI_USAGE_INVALID =
"CLI_USAGE_INVALID"
329ERROR_CODE_CFG_MISSING_SECTION =
"CFG_MISSING_SECTION"
330ERROR_CODE_CFG_MISSING_KEY =
"CFG_MISSING_KEY"
331ERROR_CODE_CFG_INVALID_TYPE =
"CFG_INVALID_TYPE"
332ERROR_CODE_CFG_INVALID_VALUE =
"CFG_INVALID_VALUE"
333ERROR_CODE_CFG_FILE_NOT_FOUND =
"CFG_FILE_NOT_FOUND"
334ERROR_CODE_CFG_GRID_PARSE =
"CFG_GRID_PARSE"
335ERROR_CODE_CFG_INCONSISTENT_COMBO =
"CFG_INCONSISTENT_COMBO"
336ERROR_CODE_DEPENDENCY_MISSING =
"DEPENDENCY_MISSING"
339 ERROR_CODE_CLI_USAGE_INVALID:
"Run 'picurv <command> --help' to see valid argument combinations.",
340 ERROR_CODE_CFG_MISSING_SECTION:
"Add the missing section using examples/master_template/*.yml as reference.",
341 ERROR_CODE_CFG_MISSING_KEY:
"Add the missing key in the referenced YAML file.",
342 ERROR_CODE_CFG_INVALID_TYPE:
"Fix the value type to match the documented schema in docs/pages/14_Config_Contract.md.",
343 ERROR_CODE_CFG_INVALID_VALUE:
"Adjust the value to a supported range/enum from the config reference pages.",
344 ERROR_CODE_CFG_FILE_NOT_FOUND:
"Fix the path or create the missing file before running again.",
345 ERROR_CODE_CFG_GRID_PARSE:
"Validate grid file format and numeric payload (block count, dims, coordinates).",
346 ERROR_CODE_CFG_INCONSISTENT_COMBO:
"Fix conflicting options/keys so the configuration is internally consistent.",
347 ERROR_CODE_DEPENDENCY_MISSING:
"Install the named optional dependency for the Python interpreter used by picurv.",
353 @brief Normalize error fields into a single-line string.
354 @param[in] value Argument passed to `_sanitize_error_field()`.
355 @return Value returned by `_sanitize_error_field()`.
359 text = str(value).strip()
362 return " ".join(text.splitlines())
366 message: str =
"", hint: str =
None, stream=
None):
368 @brief Emit one standardized error line for tooling and users.
369 @param[in] code Argument passed to `emit_structured_error()`.
370 @param[in] key Argument passed to `emit_structured_error()`.
371 @param[in] file_path Argument passed to `emit_structured_error()`.
372 @param[in] message Argument passed to `emit_structured_error()`.
373 @param[in] hint Argument passed to `emit_structured_error()`.
374 @param[in] stream Argument passed to `emit_structured_error()`.
378 resolved_hint = hint
if hint
is not None else _ERROR_HINTS.get(code,
"-")
380 f
"ERROR {_sanitize_error_field(code)} | "
381 f
"key={_sanitize_error_field(key)} | "
382 f
"file={_sanitize_error_field(file_path)} | "
383 f
"message={_sanitize_error_field(message)} | "
384 f
"hint={_sanitize_error_field(resolved_hint)}",
391 @brief Emit a structured CLI usage error and exit with code 2.
392 @param[in] message Argument passed to `fail_cli_usage()`.
393 @param[in] hint Argument passed to `fail_cli_usage()`.
396 ERROR_CODE_CLI_USAGE_INVALID,
400 hint=hint
or _ERROR_HINTS[ERROR_CODE_CLI_USAGE_INVALID],
407 @brief Split '<file>: <message>' style validation strings when possible.
408 @param[in] raw_error Argument passed to `_split_error_file_and_message()`.
409 @return Value returned by `_split_error_file_and_message()`.
411 text = str(raw_error).strip()
412 match = re.match(
r"^(?P<file>[^:]+):\s*(?P<msg>.+)$", text)
415 file_candidate = match.group(
"file").strip()
416 msg = match.group(
"msg").strip()
417 known_suffixes = (
".yml",
".yaml",
".cfg",
".picgrid",
".control",
".run",
".txt")
418 if "/" in file_candidate
or file_candidate.endswith(known_suffixes):
419 return file_candidate, msg
425 @brief Best-effort key-path extraction from free-form validation messages.
426 @param[in] message Argument passed to `_extract_key_path()`.
427 @return Value returned by `_extract_key_path()`.
429 dotted = re.search(
r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_\[\]-]+)+)\b", message)
431 return dotted.group(1)
433 bracketed = re.search(
r"\b([A-Za-z_][A-Za-z0-9_]*\[[^\]]+\](?:\[[^\]]+\])*)\b", message)
435 return bracketed.group(1)
437 quoted = re.findall(
r"'([A-Za-z0-9_.\[\]-]+)'", message)
439 if "." in token
or "[" in token
or token.isidentifier():
446 @brief Map existing validation/error messages to the standardized code set.
447 @param[in] message Argument passed to `_classify_error_code()`.
448 @return Value returned by `_classify_error_code()`.
450 msg = message.lower()
451 if "missing required section" in msg:
452 return ERROR_CODE_CFG_MISSING_SECTION
453 if "missing required key" in msg
or "missing key" in msg:
454 return ERROR_CODE_CFG_MISSING_KEY
455 if "not found" in msg
or "does not exist" in msg:
456 return ERROR_CODE_CFG_FILE_NOT_FOUND
457 if "invalid dimensions line" in msg
or "invalid coordinate row" in msg
or "grid file" in msg:
458 return ERROR_CODE_CFG_GRID_PARSE
460 "must both be periodic" in msg
461 or "inconsistent periodicity" in msg
463 or "requires --" in msg
464 or "must be 1 (auto) or exactly" in msg
466 return ERROR_CODE_CFG_INCONSISTENT_COMBO
468 "must be a mapping" in msg
469 or "must be a list" in msg
470 or "must be a string" in msg
471 or "must be a boolean" in msg
472 or "must be either" in msg
474 return ERROR_CODE_CFG_INVALID_TYPE
475 if "unsupported key" in msg
or "unsupported top-level section" in msg:
476 return ERROR_CODE_CFG_INVALID_VALUE
477 return ERROR_CODE_CFG_INVALID_VALUE
485 @brief Safely reads a YAML file and returns its content.
486 @param[in] filepath Path to the YAML file.
487 @return A dictionary containing the parsed YAML content.
488 @throws SystemExit if the file is not found or cannot be parsed.
490 if not os.path.exists(filepath):
492 ERROR_CODE_CFG_FILE_NOT_FOUND,
495 message=
"Configuration file not found.",
499 with open(filepath,
'r')
as f:
500 return yaml.safe_load(f)
501 except yaml.YAMLError
as e:
503 ERROR_CODE_CFG_INVALID_VALUE,
506 message=f
"YAML parse error: {e}",
507 hint=
"Fix YAML syntax/indentation and retry validation.",
513 @brief Write YAML with stable ordering for generated study artifacts.
514 @param[in] filepath Argument passed to `write_yaml_file()`.
515 @param[in] data Argument passed to `write_yaml_file()`.
517 os.makedirs(os.path.dirname(filepath), exist_ok=
True)
518 with open(filepath,
"w")
as f:
519 yaml.safe_dump(data, f, sort_keys=
False)
523 @brief Write JSON metadata/manifests with a stable, readable format.
524 @param[in] filepath Argument passed to `write_json_file()`.
525 @param[in] payload Argument passed to `write_json_file()`.
527 os.makedirs(os.path.dirname(filepath), exist_ok=
True)
528 with open(filepath,
"w")
as f:
529 json.dump(payload, f, indent=2, sort_keys=
True)
535 @brief Write a default runtime execution config, copying a source template when available.
536 @param[in] filepath Argument passed to `write_runtime_execution_file()`.
537 @param[in] template_source_path Argument passed to `write_runtime_execution_file()`.
538 @return Value returned by `write_runtime_execution_file()`.
540 os.makedirs(os.path.dirname(filepath), exist_ok=
True)
542 if template_source_path
and os.path.isfile(template_source_path):
543 shutil.copy2(template_source_path, filepath)
546 with open(filepath,
"w", encoding=
"utf-8")
as f:
547 f.write(DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE)
553 @brief Return True when a launcher arg token contains embedded whitespace and should be split.
554 @param[in] token Argument passed to `_launcher_arg_contains_whitespace()`.
555 @return Value returned by `_launcher_arg_contains_whitespace()`.
557 return isinstance(token, str)
and any(ch.isspace()
for ch
in token.strip())
562 @brief Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
563 @param[in] source_project_root Argument passed to `resolve_runtime_execution_seed_source()`.
564 @return Value returned by `resolve_runtime_execution_seed_source()`.
566 source_root_abs = os.path.abspath(source_project_root)
567 repo_local_runtime = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
568 if os.path.isfile(repo_local_runtime):
569 return repo_local_runtime
571 tracked_example = os.path.join(
575 RUNTIME_EXECUTION_EXAMPLE_FILENAME,
577 if os.path.isfile(tracked_example):
578 return tracked_example
584 @brief Create case-local runtime execution config if missing, seeded from repo-local config when available.
585 @param[in] case_dir Argument passed to `ensure_case_runtime_execution_config()`.
586 @param[in] source_project_root Argument passed to `ensure_case_runtime_execution_config()`.
587 @param[in] overwrite Argument passed to `ensure_case_runtime_execution_config()`.
588 @return Value returned by `ensure_case_runtime_execution_config()`.
590 case_dir_abs = os.path.abspath(case_dir)
591 dest_path = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
592 if os.path.exists(dest_path)
and not overwrite:
604 "seed_source": seed_source,
610 @brief Return True when a directory looks like the PICurv source repository root.
611 @param[in] candidate Argument passed to `is_project_root()`.
612 @return Value returned by `is_project_root()`.
616 candidate_abs = os.path.abspath(candidate)
618 os.path.isfile(os.path.join(candidate_abs,
"Makefile"))
619 and os.path.isdir(os.path.join(candidate_abs,
"src"))
620 and os.path.isdir(os.path.join(candidate_abs,
"include"))
621 and os.path.isdir(os.path.join(candidate_abs,
"picurv_cli"))
627 @brief Yield a path and all of its parents up to filesystem root.
628 @param[in] start_path Argument passed to `_iter_parent_dirs()`.
630 current = os.path.abspath(start_path)
631 if os.path.isfile(current):
632 current = os.path.dirname(current)
635 parent = os.path.dirname(current)
636 if parent == current:
643 @brief Search upward from an anchor and return the first matching project root.
644 @param[in] start_path Argument passed to `find_project_root_upwards()`.
645 @return Value returned by `find_project_root_upwards()`.
657 @brief Best-effort source repo discovery from runtime anchors.
658 @param[in] extra_anchors Argument passed to `discover_local_project_root()`.
659 @return Value returned by `discover_local_project_root()`.
661 anchors =
list(extra_anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR, SCRIPT_PATH, PROJECT_ROOT]
663 for anchor
in anchors:
666 anchor_abs = os.path.abspath(anchor)
667 if anchor_abs
in seen:
678 @brief Find the nearest case-origin metadata file from known runtime anchors.
679 @param[in] case_dir_hint Argument passed to `find_case_origin_metadata_file()`.
680 @return Value returned by `find_case_origin_metadata_file()`.
683 for candidate
in (case_dir_hint, os.getcwd(), INVOKED_SCRIPT_DIR):
686 abs_candidate = os.path.abspath(candidate)
687 if abs_candidate
not in search_roots:
688 search_roots.append(abs_candidate)
690 for root
in search_roots:
692 metadata_path = os.path.join(directory, CASE_ORIGIN_METADATA_FILENAME)
693 if os.path.isfile(metadata_path):
700 @brief Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
701 @param[in] case_dir_hint Argument passed to `load_case_origin_metadata()`.
702 @return Value returned by `load_case_origin_metadata()`.
705 if not metadata_path:
706 return None,
None,
None
708 with open(metadata_path,
"r", encoding=
"utf-8")
as f:
709 payload = json.load(f)
710 if not isinstance(payload, dict):
711 raise ValueError(
"Case origin metadata must be a JSON object.")
712 except Exception
as exc:
713 raise ValueError(f
"Failed to read case origin metadata at {metadata_path}: {exc}")
from exc
714 return os.path.dirname(metadata_path), metadata_path, payload
719 @brief Find the nearest optional execution config from runtime/case anchors.
720 @param[in] anchors Argument passed to `find_runtime_execution_config_file()`.
721 @return Value returned by `find_runtime_execution_config_file()`.
725 for candidate
in list(anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR]:
728 current = os.path.abspath(candidate)
729 if os.path.isfile(current):
730 current = os.path.dirname(current)
734 search_roots.append(current)
737 for root
in search_roots:
739 if directory
in seen_dirs:
741 seen_dirs.add(directory)
742 for filename
in RUNTIME_EXECUTION_CONFIG_FILENAMES:
743 config_path = os.path.join(directory, filename)
744 if os.path.isfile(config_path):
751 @brief Validate one execution override section while preserving missing-vs-empty semantics.
752 @param[in] payload Argument passed to `_normalize_execution_override_section()`.
753 @param[in] section_name Argument passed to `_normalize_execution_override_section()`.
754 @param[in] config_path Argument passed to `_normalize_execution_override_section()`.
755 @param[in] config_label Argument passed to `_normalize_execution_override_section()`.
756 @return Value returned by `_normalize_execution_override_section()`.
758 section = payload.get(section_name)
760 return {
"launcher":
None,
"launcher_args":
None}
761 if not isinstance(section, dict):
762 raise ValueError(f
"{config_label} at {config_path}: {section_name} must be a mapping.")
764 launcher = section.get(
"launcher")
765 if launcher
is not None and not isinstance(launcher, str):
766 raise ValueError(f
"{config_label} at {config_path}: {section_name}.launcher must be a string.")
769 if "launcher_args" in section:
770 launcher_args = section.get(
"launcher_args", [])
771 if launcher_args
is None:
773 if not isinstance(launcher_args, list):
774 raise ValueError(f
"{config_label} at {config_path}: {section_name}.launcher_args must be a list.")
775 for i, token
in enumerate(launcher_args):
776 if not isinstance(token, (str, int, float)):
778 f
"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
779 "must be a scalar CLI token."
783 f
"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
784 "must be a single CLI token; split whitespace-separated arguments into separate list items."
786 launcher_args = [str(x)
for x
in launcher_args]
789 "launcher": launcher,
790 "launcher_args": launcher_args,
796 @brief Load optional shared execution launcher config from the nearest runtime config file.
797 @param[in] config_search_anchor Argument passed to `load_runtime_execution_config()`.
798 @param[in] extra_search_anchors Argument passed to `load_runtime_execution_config()`.
799 @return Value returned by `load_runtime_execution_config()`.
802 if config_search_anchor
is not None:
803 anchors.append(config_search_anchor)
804 if extra_search_anchors:
805 anchors.extend(extra_search_anchors)
812 with open(config_path,
"r", encoding=
"utf-8")
as f:
813 payload = yaml.safe_load(f)
or {}
814 except yaml.YAMLError
as exc:
815 raise ValueError(f
"{os.path.basename(config_path)} YAML parse error at {config_path}: {exc}")
from exc
817 if not isinstance(payload, dict):
818 raise ValueError(f
"{os.path.basename(config_path)} at {config_path} must be a YAML mapping.")
820 return config_path, {
825 os.path.basename(config_path),
831 os.path.basename(config_path),
837 os.path.basename(config_path),
844 @brief Merge execution overrides, letting explicit override values win key-by-key.
845 @param[in] base Argument passed to `merge_execution_overrides()`.
846 @param[in] override Argument passed to `merge_execution_overrides()`.
847 @return Value returned by `merge_execution_overrides()`.
850 override = override
or {}
852 launcher = override.get(
"launcher")
854 launcher = base.get(
"launcher")
856 launcher_args = override.get(
"launcher_args")
857 if launcher_args
is None:
858 launcher_args = base.get(
"launcher_args")
861 "launcher": launcher,
862 "launcher_args":
None if launcher_args
is None else [str(x)
for x
in launcher_args],
868 @brief Resolve default plus context-specific execution overrides.
869 @param[in] runtime_execution_cfg Argument passed to `resolve_runtime_execution_context()`.
870 @param[in] context Argument passed to `resolve_runtime_execution_context()`.
871 @return Value returned by `resolve_runtime_execution_context()`.
873 if context
not in {
"local",
"cluster"}:
874 raise ValueError(f
"Unsupported execution context '{context}'.")
876 runtime_execution_cfg.get(
"default_execution"),
877 runtime_execution_cfg.get(f
"{context}_execution"),
883 @brief Best-effort git commit lookup for run/study manifests and case metadata.
884 @param[in] repo_root Argument passed to `get_git_commit()`.
885 @return Value returned by `get_git_commit()`.
887 cwd = repo_root
or PROJECT_ROOT
889 result = subprocess.run(
890 [
"git",
"rev-parse",
"HEAD"],
896 if result.returncode == 0:
897 return result.stdout.strip()
904 existing: dict =
None, template_managed_files=
None):
906 @brief Create or refresh case-origin metadata for repo-aware case maintenance commands.
907 @param[in] case_dir Argument passed to `write_case_origin_metadata()`.
908 @param[in] source_project_root Argument passed to `write_case_origin_metadata()`.
909 @param[in] template_name Argument passed to `write_case_origin_metadata()`.
910 @param[in] existing Argument passed to `write_case_origin_metadata()`.
911 @param[in] template_managed_files Argument passed to `write_case_origin_metadata()`.
912 @return Value returned by `write_case_origin_metadata()`.
914 payload = dict(existing
or {})
915 if "initialized_at" not in payload:
916 payload[
"initialized_at"] = datetime.now().isoformat()
917 payload[
"source_repo_root"] = os.path.abspath(source_project_root)
919 payload[
"template_name"] = template_name
920 if template_managed_files
is not None:
921 payload[
"template_managed_files"] = sorted(set(str(p)
for p
in template_managed_files))
922 payload[
"last_known_source_git_commit"] =
get_git_commit(source_project_root)
923 metadata_path = os.path.join(os.path.abspath(case_dir), CASE_ORIGIN_METADATA_FILENAME)
925 return metadata_path, payload
930 @brief Return True when make args contain an explicit target rather than only options/assignments.
931 @param[in] make_args Argument passed to `make_args_include_explicit_goal()`.
932 @return Value returned by `make_args_include_explicit_goal()`.
937 options_with_value = {
938 "-C",
"-f",
"-I",
"-j",
"-l",
"-o",
"-W",
939 "--directory",
"--file",
"--makefile",
"--include-dir",
"--jobs",
940 "--load-average",
"--max-load",
"--old-file",
"--assume-old",
941 "--what-if",
"--new-file",
"--assume-new",
943 assignment_pattern = re.compile(
r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
946 for token
in make_args:
950 if token
in options_with_value:
953 if token.startswith(
"-"):
955 if assignment_pattern.match(token):
963 @brief Resolve case directory, source repo root, and optional template metadata.
964 @param[in] case_dir_hint Argument passed to `resolve_case_origin_context()`.
965 @param[in] source_root_override Argument passed to `resolve_case_origin_context()`.
966 @param[in] template_name_override Argument passed to `resolve_case_origin_context()`.
967 @return Value returned by `resolve_case_origin_context()`.
971 if metadata_case_dir:
972 case_dir = metadata_case_dir
974 case_dir = os.path.abspath(case_dir_hint
or os.getcwd())
976 source_project_root = source_root_override
977 if source_project_root:
978 source_project_root = os.path.abspath(source_project_root)
979 elif isinstance(metadata, dict)
and isinstance(metadata.get(
"source_repo_root"), str):
980 source_project_root = os.path.abspath(metadata[
"source_repo_root"])
984 template_name = template_name_override
985 if not template_name
and isinstance(metadata, dict):
986 template_name = metadata.get(
"template_name")
989 "case_dir": case_dir,
990 "metadata_path": metadata_path,
991 "metadata": metadata
or {},
992 "source_project_root": source_project_root,
993 "template_name": template_name,
999 @brief Validate that a source repo root was resolved and is structurally valid.
1000 @param[in] candidate Argument passed to `require_project_root()`.
1001 @param[in] purpose Argument passed to `require_project_root()`.
1002 @return Value returned by `require_project_root()`.
1006 f
"Could not determine the PICurv source repository for {purpose}. "
1007 "Run this command from an initialized case directory or pass --source-root."
1009 candidate_abs = os.path.abspath(candidate)
1012 f
"Resolved source repository for {purpose} is not a valid PICurv root: {candidate_abs}"
1014 return candidate_abs
1019 @brief Validate that a target case directory exists and is not the source repo root.
1020 @param[in] case_dir Argument passed to `require_existing_case_dir()`.
1021 @param[in] purpose Argument passed to `require_existing_case_dir()`.
1022 @param[in] source_project_root Argument passed to `require_existing_case_dir()`.
1023 @return Value returned by `require_existing_case_dir()`.
1026 raise ValueError(f
"Could not determine the case directory for {purpose}. Pass --case-dir.")
1027 case_dir_abs = os.path.abspath(case_dir)
1028 if not os.path.isdir(case_dir_abs):
1029 raise ValueError(f
"Case directory for {purpose} does not exist: {case_dir_abs}")
1030 if source_project_root
and os.path.abspath(source_project_root) == case_dir_abs:
1032 f
"Refusing to run {purpose} against the source repository root itself: {case_dir_abs}"
1039 @brief Resolve an example template directory inside the source repository.
1040 @param[in] source_project_root Argument passed to `resolve_template_directory()`.
1041 @param[in] template_name Argument passed to `resolve_template_directory()`.
1042 @return Value returned by `resolve_template_directory()`.
1044 if not template_name:
1046 "Template name is required for config sync. Re-run with --template-name or from a case initialized by current picurv."
1048 template_dir = os.path.join(source_project_root,
"examples", template_name)
1049 if not os.path.isdir(template_dir):
1050 raise ValueError(f
"Case template '{template_name}' not found at '{template_dir}'")
1056 @brief List all files in a template directory as case-relative paths.
1057 @param[in] template_dir Argument passed to `list_template_relative_files()`.
1058 @param[in] excluded_rel_paths Argument passed to `list_template_relative_files()`.
1059 @return Value returned by `list_template_relative_files()`.
1061 template_dir_abs = os.path.abspath(template_dir)
1062 if not os.path.isdir(template_dir_abs):
1063 raise ValueError(f
"Template directory not found: {template_dir_abs}")
1064 excluded = set(excluded_rel_paths
or [])
1066 for root, _, files
in os.walk(template_dir_abs):
1067 rel_root = os.path.relpath(root, template_dir_abs)
1068 for filename
in sorted(files):
1069 rel_path = filename
if rel_root ==
"." else os.path.join(rel_root, filename)
1070 if rel_path
in excluded:
1072 relative_paths.append(rel_path)
1073 return relative_paths
1078 @brief List binary artifacts currently available in the source repo bin directory.
1079 @param[in] source_project_root Argument passed to `list_source_binaries()`.
1080 @return Value returned by `list_source_binaries()`.
1082 source_bin_dir = os.path.join(os.path.abspath(source_project_root),
"bin")
1083 if not os.path.isdir(source_bin_dir):
1084 raise ValueError(f
"Source bin directory not found: {source_bin_dir}. Run 'picurv build' first.")
1086 f
for f
in os.listdir(source_bin_dir)
1087 if os.path.isfile(os.path.join(source_bin_dir, f))
and f !=
"picurv"
1090 raise ValueError(f
"Source bin directory contains no files: {source_bin_dir}")
1091 return source_bin_dir, binaries
1096 @brief Copy current source-repo binaries into a case directory for version-pinning.
1097 @param[in] case_dir Argument passed to `sync_case_binaries()`.
1098 @param[in] source_project_root Argument passed to `sync_case_binaries()`.
1099 @return Value returned by `sync_case_binaries()`.
1101 case_dir_abs = os.path.abspath(case_dir)
1102 os.makedirs(case_dir_abs, exist_ok=
True)
1105 for binary_name
in binaries:
1106 source_path = os.path.join(source_bin_dir, binary_name)
1107 dest_path = os.path.join(case_dir_abs, binary_name)
1108 shutil.copy2(source_path, dest_path)
1109 copied.append(dest_path)
1114 prune: bool =
False, managed_rel_paths=
None):
1116 @brief Sync template files into a case directory, preserving modified files unless overwrite is requested.
1117 @param[in] case_dir Argument passed to `sync_case_template_files()`.
1118 @param[in] template_dir Argument passed to `sync_case_template_files()`.
1119 @param[in] overwrite Argument passed to `sync_case_template_files()`.
1120 @param[in] prune Argument passed to `sync_case_template_files()`.
1121 @param[in] managed_rel_paths Argument passed to `sync_case_template_files()`.
1122 @return Value returned by `sync_case_template_files()`.
1124 case_dir_abs = os.path.abspath(case_dir)
1125 template_dir_abs = os.path.abspath(template_dir)
1126 if not os.path.isdir(template_dir_abs):
1127 raise ValueError(f
"Template directory not found: {template_dir_abs}")
1132 "skipped_modified": [],
1135 "prune_requested_without_tracking":
False,
1137 excluded_rel_paths = {RUNTIME_EXECUTION_EXAMPLE_FILENAME}
1140 excluded_rel_paths=excluded_rel_paths,
1142 current_template_set = set(current_template_files)
1144 for root, _, files
in os.walk(template_dir_abs):
1145 rel_root = os.path.relpath(root, template_dir_abs)
1146 for filename
in sorted(files):
1147 src_path = os.path.join(root, filename)
1148 rel_path = filename
if rel_root ==
"." else os.path.join(rel_root, filename)
1149 if rel_path
in excluded_rel_paths:
1151 dest_path = os.path.join(case_dir_abs, rel_path)
1152 os.makedirs(os.path.dirname(dest_path), exist_ok=
True)
1154 if not os.path.exists(dest_path):
1155 shutil.copy2(src_path, dest_path)
1156 summary[
"copied"].append(dest_path)
1159 if filecmp.cmp(src_path, dest_path, shallow=
False):
1160 summary[
"unchanged"].append(dest_path)
1164 shutil.copy2(src_path, dest_path)
1165 summary[
"overwritten"].append(dest_path)
1167 summary[
"skipped_modified"].append(dest_path)
1169 managed_set = set(managed_rel_paths
or [])
1172 summary[
"prune_requested_without_tracking"] =
True
1173 for rel_path
in sorted(managed_set - current_template_set):
1174 dest_path = os.path.join(case_dir_abs, rel_path)
1175 if os.path.isfile(dest_path):
1176 os.remove(dest_path)
1177 summary[
"pruned"].append(dest_path)
1179 summary[
"template_managed_files"] = current_template_files
1185 @brief Compute source/case drift across commits, binaries, and template-managed files.
1186 @param[in] case_dir Argument passed to `compute_case_source_status()`.
1187 @param[in] source_project_root Argument passed to `compute_case_source_status()`.
1188 @param[in] template_name Argument passed to `compute_case_source_status()`.
1189 @param[in] metadata Argument passed to `compute_case_source_status()`.
1190 @return Value returned by `compute_case_source_status()`.
1192 case_dir_abs = os.path.abspath(case_dir)
1193 source_root_abs = os.path.abspath(source_project_root)
1194 metadata = metadata
or {}
1196 "case_dir": case_dir_abs,
1197 "source_repo_root": source_root_abs,
1198 "metadata_present": bool(metadata),
1199 "template_name": template_name,
1200 "last_known_source_git_commit": metadata.get(
"last_known_source_git_commit"),
1203 status[
"source_commit_changed"] = (
1204 bool(status[
"last_known_source_git_commit"])
1205 and bool(status[
"current_source_git_commit"])
1206 and status[
"last_known_source_git_commit"] != status[
"current_source_git_commit"]
1210 "source_bin_present":
False,
1211 "source_bin_missing": [],
1212 "case_bin_missing": [],
1213 "case_bin_different": [],
1214 "case_bin_current": [],
1218 binary_status[
"source_bin_present"] =
True
1219 for binary_name
in binaries:
1220 source_path = os.path.join(source_bin_dir, binary_name)
1221 case_path = os.path.join(case_dir_abs, binary_name)
1222 if not os.path.isfile(case_path):
1223 binary_status[
"case_bin_missing"].append(binary_name)
1224 elif filecmp.cmp(source_path, case_path, shallow=
False):
1225 binary_status[
"case_bin_current"].append(binary_name)
1227 binary_status[
"case_bin_different"].append(binary_name)
1228 except ValueError
as exc:
1229 binary_status[
"source_bin_missing"].append(str(exc))
1230 status[
"binaries"] = binary_status
1233 "template_available":
False,
1234 "template_files": [],
1235 "case_missing_files": [],
1236 "case_modified_files": [],
1237 "case_current_files": [],
1238 "template_removed_since_last_sync": [],
1239 "tracking_available": isinstance(metadata.get(
"template_managed_files"), list),
1246 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
1248 config_status[
"template_available"] =
True
1249 config_status[
"template_files"] = template_files
1250 for rel_path
in template_files:
1251 src_path = os.path.join(template_dir, rel_path)
1252 case_path = os.path.join(case_dir_abs, rel_path)
1253 if not os.path.isfile(case_path):
1254 config_status[
"case_missing_files"].append(rel_path)
1255 elif filecmp.cmp(src_path, case_path, shallow=
False):
1256 config_status[
"case_current_files"].append(rel_path)
1258 config_status[
"case_modified_files"].append(rel_path)
1259 managed_files = metadata.get(
"template_managed_files")
1260 if isinstance(managed_files, list):
1261 config_status[
"template_removed_since_last_sync"] = sorted(set(managed_files) - set(template_files))
1264 status[
"config"] = config_status
1266 case_runtime_cfg = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1267 repo_runtime_seed = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1269 "case_config_present": os.path.isfile(case_runtime_cfg),
1270 "repo_seed_present": os.path.isfile(repo_runtime_seed),
1271 "case_matches_repo_seed":
False,
1273 if runtime_status[
"case_config_present"]
and runtime_status[
"repo_seed_present"]:
1274 runtime_status[
"case_matches_repo_seed"] = filecmp.cmp(
1279 status[
"runtime_execution"] = runtime_status
1285 @brief Render human-readable source/case drift details.
1286 @param[in] status Argument passed to `print_case_source_status()`.
1288 print(f
"[INFO] Case directory : {status['case_dir']}")
1289 print(f
"[INFO] Source repo : {status['source_repo_root']}")
1290 print(f
"[INFO] Template : {status.get('template_name') or '(unknown)'}")
1291 if status.get(
"last_known_source_git_commit"):
1292 print(f
"[INFO] Last synced commit : {status['last_known_source_git_commit']}")
1293 if status.get(
"current_source_git_commit"):
1294 print(f
"[INFO] Current src commit : {status['current_source_git_commit']}")
1295 print(f
"[INFO] Source changed : {'yes' if status.get('source_commit_changed') else 'no'}")
1297 binaries = status[
"binaries"]
1298 if binaries[
"source_bin_present"]:
1300 f
"[INFO] Binaries : current={len(binaries['case_bin_current'])} "
1301 f
"changed={len(binaries['case_bin_different'])} missing={len(binaries['case_bin_missing'])}"
1304 print(
"[INFO] Binaries : source bin/ unavailable")
1306 config = status[
"config"]
1307 if config[
"template_available"]:
1309 f
"[INFO] Template files : current={len(config['case_current_files'])} "
1310 f
"modified={len(config['case_modified_files'])} missing={len(config['case_missing_files'])}"
1312 if config[
"tracking_available"]:
1313 print(f
"[INFO] Prune candidates : {len(config['template_removed_since_last_sync'])}")
1315 print(
"[INFO] Prune candidates : tracking unavailable")
1316 elif status.get(
"template_name"):
1317 print(
"[INFO] Template files : template unavailable in source repo")
1319 runtime_cfg = status.get(
"runtime_execution", {})
1321 f
"[INFO] Runtime config : case={'yes' if runtime_cfg.get('case_config_present') else 'no'} "
1322 f
"repo-seed={'yes' if runtime_cfg.get('repo_seed_present') else 'no'} "
1323 f
"matches-repo-seed={'yes' if runtime_cfg.get('case_matches_repo_seed') else 'no'}"
1329 @brief Report source/case drift for an initialized case directory.
1330 @param[in] args Command-line style argument list supplied to the function.
1334 case_dir_hint=getattr(args,
"case_dir",
None),
1335 source_root_override=getattr(args,
"source_root",
None),
1336 template_name_override=getattr(args,
"template_name",
None),
1342 source_project_root,
1343 template_name=context.get(
"template_name"),
1344 metadata=context.get(
"metadata"),
1346 except ValueError
as exc:
1347 print(f
"[FATAL] {exc}", file=sys.stderr)
1350 if getattr(args,
"output_format",
"text") ==
"json":
1351 print(json.dumps(status, indent=2, sort_keys=
True))
1357 @brief Resolve a potentially relative path against a source YAML file path.
1358 @param[in] anchor_file Argument passed to `resolve_path()`.
1359 @param[in] candidate Argument passed to `resolve_path()`.
1360 @return Value returned by `resolve_path()`.
1362 if candidate
is None:
1364 if os.path.isabs(candidate):
1365 return os.path.abspath(candidate)
1366 return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(anchor_file)), candidate))
1369POST_RUN_CONTROL_ALIASES = {
1370 "start_step": (
"start_step",
"startTime"),
1371 "end_step": (
"end_step",
"endTime"),
1372 "step_interval": (
"step_interval",
"timeStep"),
1376GRID_GENERATOR_HYPHEN_KEY_HINTS = {
1377 "config-file":
"config_file",
1378 "grid-type":
"grid_type",
1379 "cli-args":
"cli_args",
1380 "output-file":
"output_file",
1381 "stats-file":
"stats_file",
1382 "vts-file":
"vts_file",
1388 @brief Return the first defined value from a mapping across alias keys.
1389 @param[in] mapping Argument passed to `_mapping_value_with_aliases()`.
1390 @param[in] default Argument passed to `_mapping_value_with_aliases()`.
1391 @param[in] keys Argument passed to `_mapping_value_with_aliases()`.
1392 @return Value returned by `_mapping_value_with_aliases()`.
1394 if not isinstance(mapping, dict):
1398 return mapping.get(key)
1404 @brief Resolve post run_control values with backwards-compatible legacy aliases.
1405 @param[in] post_cfg Argument passed to `get_post_run_control_value()`.
1406 @param[in] canonical_key Argument passed to `get_post_run_control_value()`.
1407 @param[in] default Argument passed to `get_post_run_control_value()`.
1408 @return Value returned by `get_post_run_control_value()`.
1410 aliases = POST_RUN_CONTROL_ALIASES.get(canonical_key, (canonical_key,))
1411 rc = post_cfg.get(
"run_control", {})
1417 @brief Warn when grid.generator uses unsupported hyphenated wrapper keys.
1418 @param[in] generator grid.generator mapping from case.yml.
1419 @param[in] case_path Case file path for diagnostics.
1420 @param[in,out] warnings Warning list to append to.
1422 if not isinstance(generator, dict):
1424 for bad_key, expected_key
in GRID_GENERATOR_HYPHEN_KEY_HINTS.items():
1425 if bad_key
in generator
and bad_key != expected_key:
1427 f
"{case_path}: grid.generator.{bad_key} is ignored; use grid.generator.{expected_key}."
1433 @brief Return source_data as a mapping when valid, else an empty mapping.
1434 @param[in] post_cfg Argument passed to `get_post_source_data()`.
1435 @return Value returned by `get_post_source_data()`.
1437 source_cfg = post_cfg.get(
"source_data", {})
1438 if isinstance(source_cfg, dict):
1445 @brief Resolve the source directory template from source_data with a safe default.
1446 @param[in] post_cfg Argument passed to `get_post_source_directory_template()`.
1447 @param[in] default Argument passed to `get_post_source_directory_template()`.
1448 @return Value returned by `get_post_source_directory_template()`.
1455 @brief Return post input_extensions, preferring io.* and tolerating legacy source_data.* placement.
1456 @param[in] post_cfg Argument passed to `get_post_input_extensions()`.
1457 @return Value returned by `get_post_input_extensions()`.
1459 io_cfg = post_cfg.get(
"io", {})
1460 io_ext = io_cfg.get(
"input_extensions")
if isinstance(io_cfg, dict)
else None
1461 if isinstance(io_ext, dict):
1465 if isinstance(source_ext, dict):
1473 @brief Return normalized statistics pipeline tokens that will be written into post.run.
1474 @param[in] post_cfg Argument passed to `get_post_statistics_task_tokens()`.
1475 @return Value returned by `get_post_statistics_task_tokens()`.
1477 stats_cfg = post_cfg.get(
"statistics_pipeline")
1479 if isinstance(stats_cfg, list):
1480 stats_entries = stats_cfg
1481 elif isinstance(stats_cfg, dict):
1482 stats_entries = stats_cfg.get(
"tasks", [])
1485 for entry
in stats_entries:
1486 if isinstance(entry, str):
1488 elif isinstance(entry, dict):
1489 task_name = entry.get(
"task")
1501 @brief Resolve the solver output root from monitor.yml, preserving the default layout.
1502 @param[in] monitor_cfg Argument passed to `get_monitor_output_directory()`.
1503 @param[in] default Argument passed to `get_monitor_output_directory()`.
1504 @return Value returned by `get_monitor_output_directory()`.
1506 io_cfg = monitor_cfg.get(
"io")
1507 if isinstance(io_cfg, dict):
1508 directories = io_cfg.get(
"directories")
1509 if isinstance(directories, dict):
1510 output_dir = directories.get(
"output")
1511 if isinstance(output_dir, str)
and output_dir.strip():
1512 return output_dir.strip()
1519 @brief Resolve the statistics CSV prefix, preserving legacy top-level override support.
1520 @param[in] post_cfg Argument passed to `get_post_statistics_output_prefix()`.
1521 @param[in] default Argument passed to `get_post_statistics_output_prefix()`.
1522 @return Value returned by `get_post_statistics_output_prefix()`.
1524 stats_cfg = post_cfg.get(
"statistics_pipeline")
1525 if isinstance(stats_cfg, dict):
1526 prefix = stats_cfg.get(
"output_prefix")
1527 if isinstance(prefix, str)
and prefix.strip():
1528 return prefix.strip()
1530 legacy_prefix = post_cfg.get(
"statistics_output_prefix")
1531 if isinstance(legacy_prefix, str)
and legacy_prefix.strip():
1532 return legacy_prefix.strip()
1539 @brief Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
1540 @param[in] post_cfg Argument passed to `resolve_post_statistics_output_prefix()`.
1541 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
1542 @param[in] default Argument passed to `resolve_post_statistics_output_prefix()`.
1543 @return Value returned by `resolve_post_statistics_output_prefix()`.
1546 if os.path.isabs(prefix):
1549 if os.path.dirname(prefix):
1553 return os.path.join(monitor_output_dir,
"statistics", prefix)
1558 @brief Predict statistics CSV output paths relative to the postprocessor runtime cwd.
1559 @param[in] post_cfg Argument passed to `get_post_statistics_output_artifacts()`.
1560 @param[in] run_dir Argument passed to `get_post_statistics_output_artifacts()`.
1561 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
1562 @return Value returned by `get_post_statistics_output_artifacts()`.
1565 "ComputeMSD":
"_msd.csv",
1568 if os.path.isabs(prefix):
1569 base_path = os.path.abspath(prefix)
1571 base_path = os.path.abspath(os.path.join(run_dir, prefix))
1575 suffix = token_to_suffix.get(token)
1577 output_paths.append(base_path + suffix)
1579 return list(dict.fromkeys(output_paths))
1584 @brief Build the flat key=value mapping consumed by the C post-processor.
1585 @param[in] post_cfg Argument passed to `build_post_recipe_config()`.
1586 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
1587 @return Value returned by `build_post_recipe_config()`.
1595 eulerian_pipeline_parts = []
1596 if post_cfg.get(
'global_operations', {}).get(
'dimensionalize',
False):
1597 eulerian_pipeline_parts.append(
'DimensionalizeAllLoadedFields')
1599 for task
in post_cfg.get(
'eulerian_pipeline', []):
1600 task_name = task.get(
'task')
1601 if task_name ==
'q_criterion':
1602 eulerian_pipeline_parts.append(
'ComputeQCriterion')
1603 elif task_name ==
'normalize_field':
1604 field = task.get(
'field',
'P')
1605 eulerian_pipeline_parts.append(f
'NormalizeRelativeField:{field}')
1606 ref_point = task.get(
'reference_point', [1, 1, 1])
1607 c_config[
'reference_ip'] = ref_point[0]
1608 c_config[
'reference_jp'] = ref_point[1]
1609 c_config[
'reference_kp'] = ref_point[2]
1610 elif task_name ==
'nodal_average':
1611 in_field = task.get(
'input_field')
1612 out_field = task.get(
'output_field')
1613 if in_field
and out_field:
1614 eulerian_pipeline_parts.append(f
'CellToNodeAverage:{in_field}>{out_field}')
1616 if eulerian_pipeline_parts:
1617 c_config[
'process_pipeline'] =
";".join(eulerian_pipeline_parts)
1619 lagrangian_pipeline_parts = []
1620 for task
in post_cfg.get(
'lagrangian_pipeline', []):
1621 task_name = task.get(
'task')
1622 if task_name ==
'specific_ke':
1623 in_field = task.get(
'input_field')
1624 out_field = task.get(
'output_field')
1625 if in_field
and out_field:
1626 lagrangian_pipeline_parts.append(f
'ComputeSpecificKE:{in_field}>{out_field}')
1627 if lagrangian_pipeline_parts:
1628 c_config[
'particle_pipeline'] =
";".join(lagrangian_pipeline_parts)
1631 statistics_output_prefix =
None
1632 stats_cfg = post_cfg.get(
'statistics_pipeline')
1633 if isinstance(stats_cfg, dict):
1634 statistics_output_prefix = stats_cfg.get(
'output_prefix')
1636 if statistics_pipeline_parts:
1637 c_config[
'statistics_pipeline'] =
";".join(statistics_pipeline_parts)
1639 elif statistics_output_prefix
is None:
1640 statistics_output_prefix = post_cfg.get(
'statistics_output_prefix')
1641 if statistics_output_prefix:
1642 c_config[
'statistics_output_prefix'] = statistics_output_prefix
1644 io = post_cfg.get(
'io', {})
1645 c_config[
'output_prefix'] = io.get(
'output_directory',
'viz') +
'/' + io.get(
'output_filename_prefix',
'Field')
1646 c_config[
'particle_output_prefix'] = io.get(
'output_directory',
'viz') +
'/' + io.get(
'particle_filename_prefix',
'Particle')
1647 c_config[
'output_particles'] = io.get(
'output_particles',
False)
1648 c_config[
'particle_output_freq'] = io.get(
'particle_subsampling_frequency', 1)
1649 c_config[
'output_fields_instantaneous'] =
",".join(io.get(
'eulerian_fields', []))
1650 c_config[
'output_fields_averaged'] =
",".join(io.get(
'eulerian_fields_averaged', []))
1651 c_config[
'particle_fields_instantaneous'] =
",".join(io.get(
'particle_fields', []))
1653 if isinstance(input_extensions, dict):
1654 e_ext = input_extensions.get(
'eulerian')
1655 p_ext = input_extensions.get(
'particle')
1657 c_config[
'eulerianExt'] = str(e_ext).strip().lstrip(
'.')
1659 c_config[
'particleExt'] = str(p_ext).strip().lstrip(
'.')
1662 if source_directory
is not None:
1663 c_config[
'source_directory'] = source_directory
1670 @brief Normalize post recipe settings into a stable signature mapping.
1671 @param[in] recipe_cfg Argument passed to `normalize_post_recipe_signature()`.
1672 @return Value returned by `normalize_post_recipe_signature()`.
1675 for key, value
in (recipe_cfg
or {}).items():
1676 if key
in POST_RECIPE_SIGNATURE_EXCLUDED_KEYS
or value
is None:
1678 if isinstance(value, bool):
1679 text =
'true' if value
else 'false'
1681 text = str(value).strip()
1682 if text.lower()
in {
'true',
'false'}:
1685 signature[str(key)] = text
1691 @brief Return normalized recipe signature plus SHA-256 fingerprint.
1692 @param[in] recipe_cfg Argument passed to `compute_post_recipe_fingerprint()`.
1693 @return Value returned by `compute_post_recipe_fingerprint()`.
1696 payload = json.dumps(signature, sort_keys=
True, separators=(
',',
':')).encode(
'utf-8')
1697 return signature, hashlib.sha256(payload).hexdigest()
1702 @brief Parse an existing generated post.run file into a key/value mapping.
1703 @param[in] post_recipe_path Argument passed to `parse_post_recipe_file()`.
1704 @return Value returned by `parse_post_recipe_file()`.
1706 if not post_recipe_path
or not os.path.isfile(post_recipe_path):
1709 with open(post_recipe_path,
'r', encoding=
'utf-8', errors=
'replace')
as f:
1711 line = raw_line.strip()
1712 if not line
or line.startswith(
'#')
or '=' not in line:
1714 key, value = line.split(
'=', 1)
1715 recipe_cfg[key.strip()] = value.strip()
1721 @brief Return the JSON resume metadata path for a run directory.
1722 @param[in] run_dir Argument passed to `get_post_resume_state_path()`.
1723 @return Value returned by `get_post_resume_state_path()`.
1725 return os.path.join(run_dir,
'config', POST_RESUME_STATE_FILENAME)
1730 @brief Return lock-wrapper related paths for a run directory.
1731 @param[in] run_dir Argument passed to `get_post_lock_paths()`.
1732 @return Value returned by `get_post_lock_paths()`.
1734 scheduler_dir = os.path.join(run_dir,
'scheduler')
1736 'lock_file': os.path.join(scheduler_dir, POST_LOCK_FILENAME),
1737 'metadata_file': os.path.join(scheduler_dir, POST_LOCK_METADATA_FILENAME),
1738 'wrapper_path': os.path.join(scheduler_dir, POST_LOCK_WRAPPER_FILENAME),
1744 @brief Resolve the absolute post output directory for the current recipe.
1745 @param[in] run_dir Argument passed to `_post_output_directory_abs()`.
1746 @param[in] post_cfg Argument passed to `_post_output_directory_abs()`.
1747 @return Value returned by `_post_output_directory_abs()`.
1749 io_cfg = post_cfg.get(
'io', {})
or {}
1750 return os.path.abspath(os.path.join(run_dir, io_cfg.get(
'output_directory',
'viz')))
1755 @brief Return whether the current post recipe expects Eulerian VTK output artifacts.
1756 @param[in] post_cfg Argument passed to `_post_requests_eulerian_output()`.
1757 @return Value returned by `_post_requests_eulerian_output()`.
1759 io_cfg = post_cfg.get(
'io', {})
or {}
1760 return bool(io_cfg.get(
'eulerian_fields'))
1765 @brief Return whether the current post recipe expects particle VTP output artifacts.
1766 @param[in] post_cfg Argument passed to `_post_requests_particle_output()`.
1767 @return Value returned by `_post_requests_particle_output()`.
1769 io_cfg = post_cfg.get(
'io', {})
or {}
1770 return bool(io_cfg.get(
'output_particles'))
and bool(io_cfg.get(
'particle_fields'))
1775 @brief Return whether the current post recipe expects statistics CSV artifacts.
1776 @param[in] post_cfg Argument passed to `_post_requests_statistics()`.
1777 @return Value returned by `_post_requests_statistics()`.
1784 @brief Return whether the current post recipe requires particle source files to be present.
1785 @param[in] post_cfg Argument passed to `_post_needs_particle_source()`.
1786 @return Value returned by `_post_needs_particle_source()`.
1788 io_cfg = post_cfg.get(
'io', {})
or {}
1789 return bool(io_cfg.get(
'output_particles'))
or bool(post_cfg.get(
'lagrangian_pipeline'))
or _post_requests_statistics(post_cfg)
1794 @brief Yield configured post-processing steps inclusively.
1795 @param[in] start_step Argument passed to `_iter_post_steps()`.
1796 @param[in] end_step Argument passed to `_iter_post_steps()`.
1797 @param[in] step_interval Argument passed to `_iter_post_steps()`.
1799 if step_interval <= 0
or end_step < start_step:
1802 while step <= end_step:
1804 step += step_interval
1809 @brief Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
1810 @param[in] post_cfg Argument passed to `resolve_post_requested_window()`.
1811 @param[in] case_cfg Optional case configuration for end-step expansion.
1812 @return Value returned by `resolve_post_requested_window()`.
1817 if end_step < 0
and case_cfg:
1818 case_run = case_cfg.get(
'run_control', {})
or {}
1819 case_start = int(case_run.get(
'start_step', 0)
or 0)
1820 case_total = int(case_run.get(
'total_steps', 0)
or 0)
1821 end_step = case_start + case_total
1822 return start_step, end_step, step_interval
1827 @brief Return a copy of post_cfg with resolved source dir and optional effective bounds.
1828 @param[in] post_cfg Argument passed to `prepare_effective_post_config()`.
1829 @param[in] resolved_source_dir Argument passed to `prepare_effective_post_config()`.
1830 @param[in] start_step Argument passed to `prepare_effective_post_config()`.
1831 @param[in] end_step Argument passed to `prepare_effective_post_config()`.
1832 @return Value returned by `prepare_effective_post_config()`.
1834 effective_cfg = copy.deepcopy(post_cfg)
1835 if not isinstance(effective_cfg.get(
'source_data'), dict):
1836 effective_cfg[
'source_data'] = {}
1837 effective_cfg[
'source_data'][
'directory'] = resolved_source_dir
1838 rc = effective_cfg.setdefault(
'run_control', {})
1839 if start_step
is not None:
1840 rc[
'start_step'] = int(start_step)
1841 if end_step
is not None:
1842 rc[
'end_step'] = int(end_step)
1843 return effective_cfg
1848 @brief Scan VTK output files matching '<prefix>_<step>.<extension>'.
1849 @param[in] prefix_path Argument passed to `_scan_post_vtk_steps()`.
1850 @param[in] extension Argument passed to `_scan_post_vtk_steps()`.
1851 @return Value returned by `_scan_post_vtk_steps()`.
1853 directory = os.path.dirname(prefix_path)
1854 if not os.path.isdir(directory):
1856 basename = os.path.basename(prefix_path)
1857 pattern = re.compile(rf
'^{re.escape(basename)}_(\d+)\.{re.escape(extension)}$')
1859 for name
in os.listdir(directory):
1860 match = pattern.match(name)
1862 steps.add(int(match.group(1)))
1868 @brief Scan step ids from the first CSV column of a statistics artifact.
1869 @param[in] csv_path Argument passed to `_scan_post_statistics_csv_steps()`.
1870 @return Value returned by `_scan_post_statistics_csv_steps()`.
1872 if not os.path.isfile(csv_path):
1875 with open(csv_path,
'r', encoding=
'utf-8', errors=
'replace', newline=
'')
as f:
1876 reader = csv.reader(f)
1880 head = str(row[0]).strip().lower()
1881 if head
in {
'step',
'timestep',
'time_step'}:
1884 if step_val
is not None:
1891 @brief Collect per-family completed-step sets for the current post recipe.
1892 @param[in] run_dir Argument passed to `collect_post_completion_families()`.
1893 @param[in] post_cfg Argument passed to `collect_post_completion_families()`.
1894 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
1895 @return Value returned by `collect_post_completion_families()`.
1897 io_cfg = post_cfg.get(
'io', {})
or {}
1902 prefix = os.path.join(output_dir_abs, io_cfg.get(
'output_filename_prefix',
'Field'))
1906 prefix = os.path.join(output_dir_abs, io_cfg.get(
'particle_filename_prefix',
'Particle'))
1917 @brief Detect the highest contiguous fully completed post step for the current recipe.
1918 @param[in] run_dir Argument passed to `detect_post_completed_frontier()`.
1919 @param[in] post_cfg Argument passed to `detect_post_completed_frontier()`.
1920 @param[in] monitor_cfg Argument passed to `detect_post_completed_frontier()`.
1921 @param[in] start_step Argument passed to `detect_post_completed_frontier()`.
1922 @param[in] end_step Argument passed to `detect_post_completed_frontier()`.
1923 @param[in] step_interval Argument passed to `detect_post_completed_frontier()`.
1924 @return Value returned by `detect_post_completed_frontier()`.
1930 if all(step
in family
for family
in families):
1935 'frontier_step': frontier,
1936 'artifact_family_count': len(families),
1942 @brief Return the complete source step nearest to a target step.
1943 @param[in] steps Candidate step numbers.
1944 @param[in] target Target step number.
1945 @return Nearest candidate, or None when no candidates exist.
1949 return min(steps, key=
lambda step: (abs(step - target), step))
1954 @brief Format an optional step number for user-facing diagnostics.
1955 @param[in] step Step number or None.
1956 @return Printable step text.
1958 return 'none' if step
is None else str(step)
1963 @brief Scan source artifacts and return steps with every file required by the recipe.
1964 @param[in] source_dir Source output root directory.
1965 @param[in] monitor_cfg Parsed monitor configuration.
1966 @param[in] post_cfg Parsed post-processing configuration.
1967 @return Tuple of complete source steps and source path metadata.
1969 dirs = (monitor_cfg.get(
'io', {})
or {}).get(
'directories', {})
or {}
1970 euler_subdir = dirs.get(
'eulerian_subdir',
'eulerian')
1971 particle_subdir = dirs.get(
'particle_subdir',
'particles')
1972 euler_dir = os.path.join(source_dir, euler_subdir)
1973 particle_dir = os.path.join(source_dir, particle_subdir)
1976 euler_ext = str((input_extensions.get(
'eulerian')
or 'dat')).strip().lstrip(
'.')
1977 particle_ext = str((input_extensions.get(
'particle')
or 'dat')).strip().lstrip(
'.')
1980 for basename
in POST_REQUIRED_EULERIAN_SOURCE_BASENAMES:
1982 if os.path.isdir(euler_dir):
1983 pattern = re.compile(rf
'^{re.escape(basename)}(\d{{5}})_0\.{re.escape(euler_ext)}$')
1984 for name
in os.listdir(euler_dir):
1985 match = pattern.match(name)
1987 steps.add(int(match.group(1)))
1988 families.append(steps)
1992 if os.path.isdir(particle_dir):
1993 pattern = re.compile(rf
'^position(\d{{5}})_0\.{re.escape(particle_ext)}$')
1994 for name
in os.listdir(particle_dir):
1995 match = pattern.match(name)
1997 steps.add(int(match.group(1)))
1998 families.append(steps)
2000 complete_steps = set.intersection(*families)
if families
else set()
2001 return complete_steps, {
2002 'euler_dir': euler_dir,
2003 'particle_dir': particle_dir,
2004 'euler_ext': euler_ext,
2005 'particle_ext': particle_ext,
2011 @brief Build required source file paths for a single post-processing step.
2012 @param[in] step Requested step number.
2013 @param[in] source_scan Metadata returned by `_scan_complete_source_steps()`.
2014 @param[in] post_cfg Parsed post-processing configuration.
2015 @return Required source artifact paths.
2018 os.path.join(source_scan[
'euler_dir'], f
'{basename}{step:05d}_0.{source_scan["euler_ext"]}')
2019 for basename
in POST_REQUIRED_EULERIAN_SOURCE_BASENAMES
2022 paths.append(os.path.join(source_scan[
'particle_dir'], f
'position{step:05d}_0.{source_scan["particle_ext"]}'))
2028 @brief Detect the highest contiguous fully available source step for live post-processing.
2029 @param[in] source_dir Argument passed to `detect_post_source_frontier()`.
2030 @param[in] monitor_cfg Argument passed to `detect_post_source_frontier()`.
2031 @param[in] post_cfg Argument passed to `detect_post_source_frontier()`.
2032 @param[in] start_step Argument passed to `detect_post_source_frontier()`.
2033 @param[in] end_step Argument passed to `detect_post_source_frontier()`.
2034 @param[in] step_interval Argument passed to `detect_post_source_frontier()`.
2035 @return Value returned by `detect_post_source_frontier()`.
2038 'first_requested_step': start_step,
2039 'first_incomplete_step':
None,
2040 'missing_files_for_first_incomplete_step': [],
2041 'closest_complete_step_to_start':
None,
2042 'closest_complete_step_to_end':
None,
2044 if step_interval <= 0
or end_step < start_step
or not os.path.isdir(source_dir):
2046 'frontier_step':
None,
2047 'diagnostic': diagnostic,
2051 diagnostic[
'closest_complete_step_to_start'] =
_nearest_step(complete_steps, start_step)
2052 diagnostic[
'closest_complete_step_to_end'] =
_nearest_step(complete_steps, end_step)
2057 if not all(os.path.isfile(path)
for path
in expected_paths):
2058 diagnostic[
'first_incomplete_step'] = step
2059 diagnostic[
'missing_files_for_first_incomplete_step'] = [
2060 os.path.relpath(path, source_dir)
for path
in expected_paths
if not os.path.isfile(path)
2065 'frontier_step': frontier,
2066 'diagnostic': diagnostic,
2072 @brief Persist post resume lineage metadata for future --continue runs.
2073 @param[in] run_dir Argument passed to `persist_post_resume_state()`.
2074 @param[in] plan Argument passed to `persist_post_resume_state()`.
2075 @param[in] last_successful_requested_end_step Argument passed to `persist_post_resume_state()`.
2076 @return Value returned by `persist_post_resume_state()`.
2080 'schema_version': POST_RESUME_SCHEMA_VERSION,
2081 'run_id': plan.get(
'run_id'),
2082 'recipe_fingerprint': plan.get(
'recipe_fingerprint'),
2083 'recipe_signature': plan.get(
'recipe_signature'),
2084 'requested_start_step': plan.get(
'requested_start_step'),
2085 'requested_end_step': plan.get(
'requested_end_step'),
2086 'step_interval': plan.get(
'step_interval'),
2087 'source_directory': plan.get(
'source_data_directory'),
2088 'resume_match_source': plan.get(
'resume_match_source'),
2089 'last_successful_requested_end_step': last_successful_requested_end_step,
2090 'updated_at': datetime.now().isoformat(),
2098 @brief Return the Python wrapper used to hold an exclusive post-stage lock.
2099 @return Value returned by `_build_post_lock_wrapper_source()`.
2101 return """#!/usr/bin/env python3
2113 parser = argparse.ArgumentParser(description='PICurv post-stage lock wrapper')
2114 parser.add_argument('--lock-file', required=True)
2115 parser.add_argument('--metadata-file', required=True)
2116 parser.add_argument('--run-dir', required=True)
2117 parser.add_argument('--recipe-fingerprint', required=True)
2118 parser.add_argument('command', nargs=argparse.REMAINDER)
2119 args = parser.parse_args()
2121 command = list(args.command or [])
2122 if not command or command[0] != '--':
2123 parser.error("expected '-- <command ...>' after wrapper arguments")
2124 command = command[1:]
2126 os.makedirs(os.path.dirname(args.lock_file), exist_ok=True)
2127 fd = os.open(args.lock_file, os.O_RDWR | os.O_CREAT, 0o644)
2129 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
2130 except BlockingIOError:
2133 with open(args.metadata_file, 'r', encoding='utf-8') as handle:
2134 owner = json.load(handle)
2139 f"[FATAL] Post stage already active for {args.run_dir} "
2140 f"(pid={owner.get('pid')}, host={owner.get('host')}, started_at={owner.get('started_at')}).",
2144 print(f"[FATAL] Post stage already active for {args.run_dir}.", file=sys.stderr)
2149 'host': socket.gethostname(),
2150 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
2151 'run_dir': args.run_dir,
2152 'recipe_fingerprint': args.recipe_fingerprint,
2155 with open(args.metadata_file, 'w', encoding='utf-8') as handle:
2156 json.dump(metadata, handle, indent=2, sort_keys=True)
2160 result = subprocess.run(command)
2161 return int(result.returncode)
2164 os.remove(args.metadata_file)
2165 except FileNotFoundError:
2170if __name__ == '__main__':
2171 raise SystemExit(main())
2177 @brief Ensure the lock wrapper exists for a run directory and return its path.
2178 @param[in] run_dir Argument passed to `ensure_post_lock_wrapper()`.
2179 @return Value returned by `ensure_post_lock_wrapper()`.
2182 wrapper_path = paths[
'wrapper_path']
2184 existing_content =
None
2185 os.makedirs(os.path.dirname(wrapper_path), exist_ok=
True)
2186 if os.path.isfile(wrapper_path):
2187 with open(wrapper_path,
'r', encoding=
'utf-8', errors=
'replace')
as f:
2188 existing_content = f.read()
2189 if existing_content != content:
2190 with open(wrapper_path,
'w', encoding=
'utf-8')
as f:
2192 os.chmod(wrapper_path, 0o755)
2196def build_post_locked_command(run_dir: str, recipe_fingerprint: str, wrapped_command: list, create_wrapper: bool =
True) ->
"tuple[list, dict]":
2198 @brief Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
2199 @param[in] run_dir Argument passed to `build_post_locked_command()`.
2200 @param[in] recipe_fingerprint Argument passed to `build_post_locked_command()`.
2201 @param[in] wrapped_command Argument passed to `build_post_locked_command()`.
2202 @param[in] create_wrapper Argument passed to `build_post_locked_command()`.
2203 @return Value returned by `build_post_locked_command()`.
2209 '--lock-file', lock_paths[
'lock_file'],
2210 '--metadata-file', lock_paths[
'metadata_file'],
2211 '--run-dir', run_dir,
2212 '--recipe-fingerprint', recipe_fingerprint,
2214 ] +
list(wrapped_command)
2215 return command, lock_paths
2224 continue_requested: bool =
False,
2225 allow_source_frontier_scan: bool =
True,
2228 @brief Resolve post resume/source-availability behavior into one execution plan.
2229 @param[in] run_dir Argument passed to `build_post_execution_plan()`.
2230 @param[in] run_id Argument passed to `build_post_execution_plan()`.
2231 @param[in] case_cfg Argument passed to `build_post_execution_plan()`.
2232 @param[in] monitor_cfg Argument passed to `build_post_execution_plan()`.
2233 @param[in] post_cfg Argument passed to `build_post_execution_plan()`.
2234 @param[in] continue_requested Argument passed to `build_post_execution_plan()`.
2235 @param[in] allow_source_frontier_scan Argument passed to `build_post_execution_plan()`.
2236 @return Value returned by `build_post_execution_plan()`.
2246 state_match = bool(isinstance(state_payload, dict)
and state_payload.get(
'recipe_fingerprint') == recipe_fingerprint)
2248 legacy_post_run_path = os.path.join(run_dir,
'config',
'post.run')
2251 legacy_match = bool(legacy_recipe_signature
and legacy_recipe_signature == recipe_signature)
2253 resume_recipe_match =
False
2254 resume_match_source =
None
2255 resume_bootstrapped =
False
2256 if continue_requested:
2258 resume_recipe_match =
True
2259 resume_match_source =
'state'
2260 elif not state_payload
and legacy_match:
2261 resume_recipe_match =
True
2262 resume_match_source =
'legacy_post_run'
2263 resume_bootstrapped =
True
2269 requested_start_step,
2273 completed_frontier_step = completion_info[
'frontier_step']
2274 if completion_info[
'artifact_family_count'] == 0
and state_match:
2275 completed_frontier_step =
_parse_int_loose(state_payload.get(
'last_successful_requested_end_step'))
2277 if continue_requested
and resume_recipe_match
and completed_frontier_step
is not None:
2278 effective_start_step = completed_frontier_step + step_interval
2280 effective_start_step = requested_start_step
2282 source_frontier_step =
None
2283 source_frontier_diagnostic =
None
2284 source_frontier_deferred =
not allow_source_frontier_scan
2286 if effective_start_step > requested_end_step:
2287 skip_reason =
'already-complete-window'
2288 effective_end_step = requested_end_step
2289 elif allow_source_frontier_scan:
2291 resolved_source_dir,
2294 effective_start_step,
2298 source_frontier_step = source_frontier_info[
'frontier_step']
2299 source_frontier_diagnostic = source_frontier_info[
'diagnostic']
2300 if source_frontier_step
is None or source_frontier_step < effective_start_step:
2301 if continue_requested
and resume_recipe_match
and completed_frontier_step
is not None:
2302 skip_reason =
'already-caught-up-to-current-source-frontier'
2304 skip_reason =
'nothing-available-yet'
2305 effective_end_step =
None
2307 effective_end_step = min(requested_end_step, source_frontier_step)
2309 effective_end_step = requested_end_step
2311 effective_post_cfg =
None
2312 if skip_reason
is None:
2315 resolved_source_dir,
2316 start_step=effective_start_step,
2317 end_step=effective_end_step,
2322 'continue_requested': bool(continue_requested),
2323 'requested_start_step': requested_start_step,
2324 'requested_end_step': requested_end_step,
2325 'step_interval': step_interval,
2326 'source_data_directory': resolved_source_dir,
2327 'recipe_config': recipe_cfg,
2328 'recipe_signature': recipe_signature,
2329 'recipe_fingerprint': recipe_fingerprint,
2330 'resume_state_path': state_path,
2331 'resume_state_payload': state_payload,
2332 'resume_recipe_match': resume_recipe_match,
2333 'resume_match_source': resume_match_source,
2334 'resume_bootstrapped': resume_bootstrapped,
2335 'completed_frontier_step': completed_frontier_step,
2336 'source_frontier_step': source_frontier_step,
2337 'source_frontier_diagnostic': source_frontier_diagnostic,
2338 'source_frontier_deferred': source_frontier_deferred,
2339 'effective_start_step': effective_start_step,
2340 'effective_end_step': effective_end_step,
2341 'skip_reason': skip_reason,
2342 'resolved_post_cfg': resolved_post_cfg,
2343 'effective_post_cfg': effective_post_cfg,
2350 @brief Return True when the solver requires restart data from disk.
2351 @details Correctly identifies that analytical + init + start_step > 0 does NOT
2352 need a restart source (C code never reads from restart_dir in that case).
2353 @param[in] case_cfg Parsed case YAML dictionary.
2354 @param[in] solver_cfg Parsed solver YAML dictionary.
2355 @return True if a restart source (--restart-from or --continue) is required.
2358 start_step = int(case_cfg.get(
"run_control", {}).get(
"start_step", 0)
or 0)
2359 except (TypeError, ValueError):
2361 eulerian_source = str(
2362 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
2364 particle_restart_mode = str(
2365 (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"particles", {})
or {}).get(
"restart_mode",
"init")
2367 euler_needs = (eulerian_source ==
"load")
or (eulerian_source ==
"solve" and start_step > 0)
2368 particle_needs = (particle_restart_mode ==
"load")
2369 return euler_needs
or particle_needs
2374 @brief Resolve the output data directory within a run directory.
2375 @param[in] run_dir Path to the run directory.
2376 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2377 @return Absolute path to the output directory.
2379 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2380 output_rel = dirs.get(
"output",
"output")
2381 return os.path.abspath(os.path.join(run_dir, output_rel))
2386 @brief Resolve the restart staging directory within a run directory.
2387 @param[in] run_dir Path to the run directory.
2388 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2389 @return Absolute path to the restart directory.
2391 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2392 restart_rel = dirs.get(
"restart",
"restart")
2393 return os.path.abspath(os.path.join(run_dir, restart_rel))
2398 @brief Copy checkpoint files for a specific step from source output to target restart.
2399 @param[in] source_output Path to the source output directory containing checkpoint data.
2400 @param[in] target_restart Path to the target restart directory to populate.
2401 @param[in] start_step The step number whose checkpoint files should be copied.
2402 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
2404 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2405 euler_sub = dirs.get(
"eulerian_subdir",
"eulerian")
2406 particle_sub = dirs.get(
"particle_subdir",
"particles")
2408 if os.path.exists(target_restart):
2409 shutil.rmtree(target_restart)
2410 os.makedirs(os.path.join(target_restart, euler_sub), exist_ok=
True)
2411 os.makedirs(os.path.join(target_restart, particle_sub), exist_ok=
True)
2413 step_str = f
"{start_step:05d}"
2414 for subdir
in [euler_sub, particle_sub]:
2415 src = os.path.join(source_output, subdir)
2416 dst = os.path.join(target_restart, subdir)
2417 if not os.path.isdir(src):
2419 for f_name
in glob.glob(os.path.join(src, f
"*{step_str}_0.*")):
2420 shutil.copy2(f_name, dst)
2422 copied = glob.glob(os.path.join(target_restart,
"**", f
"*{step_str}_0.*"), recursive=
True)
2424 raise ValueError(f
"No checkpoint files found for step {start_step} in {source_output}")
2425 print(f
"[INFO] Populated restart directory with {len(copied)} file(s) for step {start_step}: {target_restart}")
2430 @brief Scan output directory for the highest step number available.
2431 @details Checks eulerian files first (ufield), then falls back to particle
2432 files (position) for analytical-mode cases that have no eulerian output.
2433 @param[in] output_dir Path to the output directory.
2434 @param[in] euler_subdir Name of the eulerian subdirectory.
2435 @param[in] particle_subdir Name of the particle subdirectory.
2436 @return The highest step number found, or None if no checkpoints exist.
2439 euler_path = os.path.join(output_dir, euler_subdir)
2440 if os.path.isdir(euler_path):
2441 pattern = _re.compile(
r"ufield(\d{5})_0\.dat")
2443 for fname
in os.listdir(euler_path):
2444 match = pattern.match(fname)
2446 steps.append(int(match.group(1)))
2449 particle_path = os.path.join(output_dir, particle_subdir)
2450 if os.path.isdir(particle_path):
2451 pattern = _re.compile(
r"position(\d{5})_0\.dat")
2453 for fname
in os.listdir(particle_path):
2454 match = pattern.match(fname)
2456 steps.append(int(match.group(1)))
2464 @brief Determine whether a study case is complete, partially complete, or empty.
2465 @param[in] run_dir Path to the case run directory.
2466 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2467 @param[in] target_final_step The step number the case should reach for completion.
2468 @return Dictionary with keys 'last_step' (int or None), 'target_step' (int),
2469 and 'status' ('complete', 'partial', or 'empty').
2471 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2472 euler_sub = dirs.get(
"eulerian_subdir",
"eulerian")
2473 particle_sub = dirs.get(
"particle_subdir",
"particles")
2476 if last_step
is not None and last_step >= target_final_step:
2478 elif last_step
is not None:
2482 return {
"last_step": last_step,
"target_step": target_final_step,
"status": status}
2487 @brief Validate that all required eulerian step files exist for "load" mode.
2488 @details Checks that ufield files exist for every step from start_step through
2489 start_step + total_steps (inclusive). Reports missing steps clearly.
2490 @param[in] source_output Path to the output directory containing eulerian data.
2491 @param[in] start_step First step that will be loaded.
2492 @param[in] total_steps Number of steps to run.
2493 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
2495 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2496 euler_sub = dirs.get(
"eulerian_subdir",
"eulerian")
2497 euler_path = os.path.join(source_output, euler_sub)
2500 for step
in range(start_step, start_step + total_steps + 1):
2501 expected = os.path.join(euler_path, f
"ufield{step:05d}_0.dat")
2502 if not os.path.isfile(expected):
2503 missing.append(step)
2506 sample = missing[:3] + ([
"..."]
if len(missing) > 6
else []) + missing[-3:]
2508 f
"Eulerian 'load' mode: {len(missing)} step file(s) missing in {euler_path}. "
2509 f
"Missing steps include: {sample}"
2515 @brief Validate that particle checkpoint files exist for the given step.
2516 @details Checks that at least a position file exists at the expected step in
2517 the particle subdirectory.
2518 @param[in] source_dir Path to the directory containing the particle subdirectory.
2519 @param[in] start_step The step number whose particle checkpoint is expected.
2520 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
2522 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2523 particle_sub = dirs.get(
"particle_subdir",
"particles")
2524 particle_path = os.path.join(source_dir, particle_sub)
2525 expected = os.path.join(particle_path, f
"position{start_step:05d}_0.dat")
2526 if not os.path.isfile(expected):
2528 f
"Particle restart_mode='load' but checkpoint not found: {expected}"
2534 @brief Read the monitor.yml from a run directory's config/ subdirectory.
2535 @param[in] run_dir Path to the run directory.
2536 @return Parsed monitor YAML dictionary.
2538 monitor_path = os.path.join(run_dir,
"config",
"monitor.yml")
2539 if not os.path.isfile(monitor_path):
2540 raise ValueError(f
"Run directory is missing config/monitor.yml: {monitor_path}")
2546 @brief Resolve the restart source directory based on --restart-from or --continue CLI flags.
2547 @details Implements the full restart resolution logic including smart resolution for
2548 --continue (checks restart/ first for user-curated data, falls back to output/)
2549 and direct reference for eulerian "load" mode.
2550 @param[in] args Parsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
2551 @param[in] case_cfg Parsed case YAML dictionary.
2552 @param[in] solver_cfg Parsed solver YAML dictionary.
2553 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2554 @param[in] run_dir Path to the current run directory.
2555 @return Tuple of (restart_source_dir, continue_mode) where restart_source_dir is the
2556 resolved path (or None) and continue_mode is a boolean.
2559 start_step = int(case_cfg.get(
"run_control", {}).get(
"start_step", 0)
or 0)
2560 except (TypeError, ValueError):
2563 total_steps = int(case_cfg.get(
"run_control", {}).get(
"total_steps", 0)
or 0)
2564 except (TypeError, ValueError):
2567 eulerian_source = str(
2568 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
2571 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2572 euler_sub = dirs.get(
"eulerian_subdir",
"eulerian")
2573 particle_sub = dirs.get(
"particle_subdir",
"particles")
2575 particle_restart_mode = str(
2576 (case_cfg.get(
"models", {}).get(
"physics", {}).get(
"particles", {})
or {}).get(
"restart_mode",
"init")
2578 particle_needs = (particle_restart_mode ==
"load")
2581 restart_from = getattr(args,
'restart_from',
None)
2582 continue_run = getattr(args,
'continue_run',
False)
2584 if restart_from
and continue_run:
2585 raise ValueError(
"--restart-from and --continue are mutually exclusive.")
2587 if continue_run
and start_step <= 0:
2589 "--continue with --solve requires run_control.start_step > 0; "
2590 "start_step=0 is a fresh start. Omit --continue to start a fresh run."
2595 source_run = os.path.abspath(restart_from)
2596 if not os.path.isdir(source_run):
2597 raise ValueError(f
"--restart-from run directory does not exist: {source_run}")
2600 if not os.path.isdir(source_output):
2601 raise ValueError(f
"Source output directory does not exist: {source_output}")
2603 if not requires_source:
2606 "[WARN] --restart-from specified but no data will be read "
2607 "(analytical + init does not need restart data).",
2612 if eulerian_source ==
"load":
2617 return source_output,
False
2624 return target_restart,
False
2628 continue_run_dir = getattr(args,
'run_dir',
None)
2629 if not continue_run_dir:
2630 raise ValueError(
"--continue requires --run-dir.")
2631 continue_run_dir = os.path.abspath(continue_run_dir)
2632 if not os.path.isdir(continue_run_dir):
2633 raise ValueError(f
"--run-dir does not exist: {continue_run_dir}")
2640 if last_step
is not None and last_step != start_step:
2642 f
"[WARN] start_step={start_step} but last checkpoint in output is step {last_step}.",
2646 if eulerian_source ==
"load":
2651 return source_output,
True
2652 elif not requires_source:
2657 euler_needs = (eulerian_source ==
"solve" and start_step > 0)
2659 step_str = f
"{start_step:05d}"
2662 needed_subs.append(euler_sub)
2664 needed_subs.append(particle_sub)
2668 if os.path.isdir(target_restart):
2669 for sub
in needed_subs:
2670 restart_has[sub] = bool(
2671 glob.glob(os.path.join(target_restart, sub, f
"*{step_str}_0.*"))
2674 all_in_restart = needed_subs
and all(restart_has.get(s,
False)
for s
in needed_subs)
2675 some_in_restart = any(restart_has.get(s,
False)
for s
in needed_subs)
2679 print(f
"[INFO] Using curated restart directory: {target_restart}")
2680 return target_restart,
True
2681 elif os.path.isdir(source_output):
2685 missing = [s
for s
in needed_subs
if not restart_has.get(s,
False)]
2686 os.makedirs(target_restart, exist_ok=
True)
2688 src = os.path.join(source_output, sub)
2689 dst = os.path.join(target_restart, sub)
2690 os.makedirs(dst, exist_ok=
True)
2691 if os.path.isdir(src):
2692 for f_name
in glob.glob(os.path.join(src, f
"*{step_str}_0.*")):
2693 shutil.copy2(f_name, dst)
2694 filled = glob.glob(os.path.join(target_restart,
"**", f
"*{step_str}_0.*"), recursive=
True)
2695 print(f
"[INFO] Merged curated restart/ with {len(missing)} component(s) from output/: {target_restart}")
2698 f
"After merge, no checkpoint files found for step {start_step} in {target_restart}"
2700 return target_restart,
True
2704 return target_restart,
True
2707 f
"Neither restart/ nor output/ contain data for step {start_step}. "
2708 f
"Checked: {target_restart}, {source_output}"
2711 elif requires_source:
2713 "Restart data required but no source specified. Use:\n"
2714 " --restart-from <run_dir> (new run from another run's data)\n"
2715 " --continue --run-dir <run_dir> (resume in same directory)"
2722 @brief Convert external grid/generator paths in case config to absolute paths.
2723 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
2724 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
2726 grid_cfg = case_cfg.get(
"grid", {})
2727 if not isinstance(grid_cfg, dict):
2729 mode = grid_cfg.get(
"mode")
2731 source_file = grid_cfg.get(
"source_file")
2732 if isinstance(source_file, str):
2733 grid_cfg[
"source_file"] =
resolve_path(case_anchor_path, source_file)
2734 legacy_cfg = grid_cfg.get(
"legacy_conversion", {})
2735 if isinstance(legacy_cfg, dict):
2736 script_path = legacy_cfg.get(
"script")
2737 if isinstance(script_path, str):
2738 legacy_cfg[
"script"] =
resolve_path(case_anchor_path, script_path)
2739 elif mode ==
"grid_gen":
2740 gen = grid_cfg.get(
"generator", {})
2741 if isinstance(gen, dict):
2742 for key
in (
"script",
"config_file"):
2744 if isinstance(val, str):
2746 ic = (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {})
2747 if isinstance(ic, dict):
2748 if str(ic.get(
"mode",
"")).strip().lower() ==
"file":
2749 source_file = ic.get(
"source_file")
2750 if isinstance(source_file, str):
2751 ic[
"source_file"] =
resolve_path(case_anchor_path, source_file)
2752 elif str(ic.get(
"generator",
"")).strip().lower() ==
"ic_gen":
2753 params = ic.get(
"params", {})
2754 if isinstance(params, dict):
2755 for key
in (
"script",
"config_file"):
2756 value = params.get(key)
2757 if isinstance(value, str):
2759 boundary_conditions = case_cfg.get(
"boundary_conditions", [])
2760 blocks = boundary_conditions
if boundary_conditions
and isinstance(boundary_conditions[0], list)
else [boundary_conditions]
2761 for block
in blocks:
2762 if not isinstance(block, list):
2765 if not isinstance(bc, dict)
or str(bc.get(
"handler",
"")).strip().lower() !=
"prescribed_flow":
2767 source = ((bc.get(
"params")
or {}).get(
"source")
or {})
2768 if not isinstance(source, dict):
2770 source_type = str(source.get(
"type",
"")).strip().lower()
2771 if source_type ==
"file":
2773 elif source_type ==
"generated":
2775 elif source_type ==
"field_slice":
2776 keys = (
"script",
"field_file",
"grid_file",
"source_case")
2780 value = source.get(key)
2781 if isinstance(value, str):
2786 target_final_step: int, cluster_cfg: dict):
2788 @brief Set up a partially-completed study case for continuation in-place.
2789 @details Updates the case config with new start_step/total_steps, sets particle
2790 restart_mode to 'load' if checkpoint exists, populates the restart
2791 directory, and regenerates the solver control file with continue_mode.
2792 Delegates all restart resolution to resolve_restart_source().
2793 @param[in] run_dir Path to the case run directory.
2794 @param[in] case_id The case identifier (e.g. 'case_0002').
2795 @param[in] last_step The last checkpoint step found in the output directory.
2796 @param[in] target_final_step The step number the case should reach for completion.
2797 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
2798 @return The absolute path to the regenerated control file.
2800 config_dir = os.path.join(run_dir,
"config")
2802 solver_cfg =
read_yaml_file(os.path.join(config_dir,
"solver.yml"))
2803 monitor_cfg =
read_yaml_file(os.path.join(config_dir,
"monitor.yml"))
2805 remaining = target_final_step - last_step
2806 case_cfg[
"run_control"][
"start_step"] = last_step
2807 case_cfg[
"run_control"][
"total_steps"] = remaining
2808 print(f
"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
2810 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2811 particle_sub = dirs.get(
"particle_subdir",
"particles")
2813 particle_ckpt = os.path.join(output_dir, particle_sub, f
"position{last_step:05d}_0.dat")
2814 particles_cfg = (case_cfg.get(
"models", {}).get(
"physics", {})
or {}).get(
"particles")
2815 if particles_cfg
is not None and os.path.isfile(particle_ckpt):
2816 current_mode = str(particles_cfg.get(
"restart_mode",
"init")).strip().lower()
2817 if current_mode !=
"load":
2818 particles_cfg[
"restart_mode"] =
"load"
2819 print(f
"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
2823 mock_args = argparse.Namespace(restart_from=
None, continue_run=
True, run_dir=run_dir)
2825 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
2829 'Case': os.path.join(config_dir,
"case.yml"),
2830 'Solver': os.path.join(config_dir,
"solver.yml"),
2831 'Monitor': os.path.join(config_dir,
"monitor.yml"),
2836 "case": case_cfg,
"case_path": source_files[
'Case'],
2837 "solver": solver_cfg,
"solver_path": source_files[
'Solver'],
2838 "monitor": monitor_cfg,
"monitor_path": source_files[
'Monitor'],
2842 run_dir, case_id, configs, cluster_tasks, monitor_files,
2843 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
2845 print(f
"[SUCCESS] {case_id}: regenerated control file for continuation")
2851 @brief Lightweight email validation for scheduler notifications.
2852 @param[in] email Argument passed to `is_valid_email()`.
2853 @return Value returned by `is_valid_email()`.
2855 if not isinstance(email, str):
2857 pattern =
r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
2858 return re.match(pattern, email.strip())
is not None
2862 @brief Normalizes user-facing statistics task names to C pipeline keywords.
2863 @param[in] task_name Task name from YAML.
2864 @return Canonical keyword accepted by C statistics pipeline.
2865 @throws ValueError if task is unsupported.
2868 if task_name
is None:
2869 raise ValueError(
"statistics task cannot be None")
2870 normalized = str(task_name).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
2871 if normalized !=
"msd":
2872 raise ValueError(f
"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
2877 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
2878 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
2880 for lineno, raw
in enumerate(file_obj, start=1):
2882 if not line
or line.startswith(
"#"):
2888 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
2889 @details Requires canonical PICGRID input with leading "PICGRID" token.
2890 Output is always written in canonical PICGRID format with header and per-block dims.
2891 @param[in] source_grid Input grid file path.
2892 @param[in] dest_grid Output grid file path.
2893 @param[in] L_ref Reference length for non-dimensionalization.
2894 @param[in] expected_nblk Optional expected block count.
2895 @return Summary dictionary with nblk, dims, and total_nodes.
2896 @throws ValueError on malformed grid.
2899 raise ValueError(
"length_ref must be non-zero when processing grid coordinates.")
2900 if not os.path.isfile(source_grid):
2901 raise ValueError(f
"Grid file not found: {source_grid}")
2903 with open(source_grid,
"r")
as fin:
2906 _, first_token = next(line_iter)
2907 except StopIteration:
2908 raise ValueError(f
"Grid file '{source_grid}' is empty.")
2910 if first_token !=
"PICGRID":
2912 f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
2915 _, nblk_line = next(line_iter)
2916 except StopIteration:
2917 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
2920 nblk = int(nblk_line)
2922 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
2924 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
2925 if expected_nblk
is not None and nblk != expected_nblk:
2927 f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
2931 for bi
in range(nblk):
2933 lineno, dim_line = next(line_iter)
2934 except StopIteration:
2935 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
2936 parts = dim_line.split()
2939 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
2942 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
2945 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
2947 if im <= 0
or jm <= 0
or km <= 0:
2949 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
2951 dims.append((im, jm, km))
2953 total_nodes_expected = sum(im * jm * km
for (im, jm, km)
in dims)
2954 os.makedirs(os.path.dirname(dest_grid), exist_ok=
True)
2955 with open(dest_grid,
"w")
as fout:
2956 fout.write(
"PICGRID\n")
2957 fout.write(f
"{nblk}\n")
2958 for (im, jm, km)
in dims:
2959 fout.write(f
"{im} {jm} {km}\n")
2961 total_nodes_seen = 0
2962 for lineno, coord_line
in line_iter:
2963 parts = coord_line.split()
2966 f
"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
2969 x = float(parts[0]) / L_ref
2970 y = float(parts[1]) / L_ref
2971 z = float(parts[2]) / L_ref
2974 f
"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
2976 total_nodes_seen += 1
2977 if total_nodes_seen > total_nodes_expected:
2979 f
"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
2981 fout.write(f
"{x:.8e} {y:.8e} {z:.8e}\n")
2983 if total_nodes_seen != total_nodes_expected:
2985 f
"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
2988 return {
"nblk": nblk,
"dims": dims,
"total_nodes": total_nodes_expected}
2992 @brief Read only the canonical PICGRID header dimensions.
2993 @param[in] source_grid Input grid file path.
2994 @param[in] expected_nblk Optional expected block count.
2995 @return List of (IM, JM, KM) node-count tuples.
2996 @throws ValueError on malformed header.
2998 if not os.path.isfile(source_grid):
2999 raise ValueError(f
"Grid file not found: {source_grid}")
3001 with open(source_grid,
"r")
as fin:
3004 _, first_token = next(line_iter)
3005 except StopIteration:
3006 raise ValueError(f
"Grid file '{source_grid}' is empty.")
3007 if first_token !=
"PICGRID":
3008 raise ValueError(f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
3011 _, nblk_line = next(line_iter)
3012 nblk = int(nblk_line)
3013 except StopIteration:
3014 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
3016 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
3018 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
3019 if expected_nblk
is not None and nblk != expected_nblk:
3020 raise ValueError(f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
3023 for bi
in range(nblk):
3025 lineno, dim_line = next(line_iter)
3026 except StopIteration:
3027 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
3028 parts = dim_line.split()
3031 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
3034 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
3037 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
3039 if im <= 0
or jm <= 0
or km <= 0:
3041 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
3043 dims.append((im, jm, km))
3048 expected_dims: tuple =
None) -> dict:
3050 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
3051 @param[in] source_slice Input PICSLICE path.
3052 @param[in] dest_slice Output staged PICSLICE path.
3053 @param[in] U_ref Reference velocity for non-dimensionalization.
3054 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
3055 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
3056 @throws ValueError on malformed slice.
3059 raise ValueError(
"velocity_ref must be non-zero when processing PICSLICE speeds.")
3060 if not os.path.isfile(source_slice):
3061 raise ValueError(f
"PICSLICE file not found: {source_slice}")
3063 with open(source_slice,
"r")
as fin:
3066 _, first_token = next(line_iter)
3067 except StopIteration:
3068 raise ValueError(f
"PICSLICE file '{source_slice}' is empty.")
3069 if first_token !=
"PICSLICE":
3070 raise ValueError(f
"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
3073 _, frame_line = next(line_iter)
3074 frame_count = int(frame_line)
3075 except StopIteration:
3076 raise ValueError(f
"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
3078 raise ValueError(f
"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
3079 if frame_count != 1:
3081 f
"PICSLICE file '{source_slice}' has frame count {frame_count}; Phase 1 supports exactly 1."
3085 lineno, dim_line = next(line_iter)
3086 except StopIteration:
3087 raise ValueError(f
"PICSLICE file '{source_slice}' missing slice dimensions.")
3088 parts = dim_line.split()
3091 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
3094 n1, n2 = (int(parts[0]), int(parts[1]))
3097 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
3099 if n1 <= 0
or n2 <= 0:
3100 raise ValueError(f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
3101 if expected_dims
is not None and (n1, n2) != tuple(expected_dims):
3103 f
"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
3107 for lineno, value_line
in line_iter:
3108 parts = value_line.split()
3111 f
"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
3114 value = float(parts[0])
3116 raise ValueError(f
"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
3117 if not math.isfinite(value):
3118 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be finite.")
3120 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
3121 values.append(value)
3123 expected_count = n1 * n2
3124 if len(values) != expected_count:
3126 f
"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
3129 os.makedirs(os.path.dirname(dest_slice), exist_ok=
True)
3130 with open(dest_slice,
"w")
as fout:
3131 fout.write(
"PICSLICE\n")
3133 fout.write(f
"{n1} {n2}\n")
3134 for value
in values:
3135 fout.write(f
"{value / U_ref:.8e}\n")
3138 "frame_count": frame_count,
3140 "value_count": len(values),
3141 "min_speed": min(values)
if values
else 0.0,
3142 "max_speed": max(values)
if values
else 0.0,
3147 @brief Convert a BC face token into a filesystem-friendly artifact token.
3148 @param[in] face Canonical face token such as -Zeta.
3149 @return Filesystem-friendly face token.
3151 return face.replace(
"+",
"pos").replace(
"-",
"neg")
3154 default_to_config_dir: bool =
False) -> str:
3156 @brief Resolve a run artifact path with run-dir-relative defaults.
3157 @param[in] run_dir Run/precompute directory root.
3158 @param[in] configured_path Optional user-provided artifact path.
3159 @param[in] default_path Default path relative to run_dir.
3160 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
3161 @return Absolute artifact path.
3163 path = configured_path
if configured_path
else default_path
3164 if not isinstance(path, str)
or not path.strip():
3165 raise ValueError(
"generated profile output_file must be a non-empty path when provided.")
3167 if os.path.isabs(path):
3168 return os.path.abspath(path)
3169 if default_to_config_dir
and os.path.dirname(path) ==
"":
3170 path = os.path.join(
"config", path)
3171 return os.path.abspath(os.path.join(run_dir, path))
3175 @brief Resolve an optional generator script override or repository default.
3176 @param[in] configured_script Optional absolute or case-relative script path.
3177 @param[in] case_path Current case.yml path used to anchor relative overrides.
3178 @param[in] default_name Repository generator filename under GENERATORS_PATH.
3179 @return Absolute generator script path.
3181 if configured_script
is None:
3182 return os.path.join(GENERATORS_PATH, default_name)
3183 if not isinstance(configured_script, str)
or not configured_script.strip():
3184 raise ValueError(f
"Generator script override for {default_name} must be a non-empty path.")
3185 script = configured_script.strip()
3186 if os.path.isabs(script):
3187 return os.path.abspath(script)
3188 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3189 return os.path.abspath(os.path.join(case_dir, script))
3193 @brief Validate square-duct Poiseuille generator parameters.
3194 @param[in] params Generator params mapping.
3195 @param[in] field_name Human-readable YAML field name for diagnostics.
3196 @return Normalized params.
3200 if not isinstance(params, dict):
3201 raise ValueError(f
"{field_name}.params must be a mapping when provided.")
3202 unknown = sorted(set(params.keys()) - {
"bulk_velocity",
"n_terms"})
3204 raise ValueError(f
"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
3205 bulk_velocity =
_to_float(params.get(
"bulk_velocity", 1.0), f
"{field_name}.params.bulk_velocity")
3206 if bulk_velocity <= 0.0:
3207 raise ValueError(f
"{field_name}.params.bulk_velocity must be positive.")
3209 n_terms = int(params.get(
"n_terms", 101))
3210 except (TypeError, ValueError):
3211 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
3212 if n_terms <= 0
or n_terms % 2 == 0:
3213 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
3214 return {
"bulk_velocity": bulk_velocity,
"n_terms": n_terms}
3216GENERATED_PROFILE_GENERATORS = {
"square_duct_poiseuille"}
3220 @brief Validate a prescribed_flow field_slice source block.
3221 @param[in] source Source mapping from case.yml.
3222 @param[in] field_name Human-readable YAML path for diagnostics.
3223 @return Normalized source mapping.
3236 unknown = sorted(set(source.keys()) - allowed)
3238 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
3239 field_file = source.get(
"field_file")
3240 grid_file = source.get(
"grid_file")
3241 if not isinstance(field_file, str)
or not field_file.strip():
3242 raise ValueError(f
"{field_name}.field_file must be a non-empty path.")
3243 if not isinstance(grid_file, str)
or not grid_file.strip():
3244 raise ValueError(f
"{field_name}.grid_file must be a non-empty path.")
3245 if source.get(
"source_case")
is None and source.get(
"velocity_scale")
is None:
3246 raise ValueError(f
"{field_name} requires source_case or velocity_scale.")
3249 "type":
"field_slice",
3250 "field_file": field_file.strip(),
3251 "grid_file": grid_file.strip(),
3254 if source.get(
"script")
is not None:
3255 script = source.get(
"script")
3256 if not isinstance(script, str)
or not script.strip():
3257 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
3258 normalized[
"script"] = script.strip()
3259 if source.get(
"source_case")
is not None:
3260 source_case = source.get(
"source_case")
3261 if not isinstance(source_case, str)
or not source_case.strip():
3262 raise ValueError(f
"{field_name}.source_case must be a non-empty path when provided.")
3263 normalized[
"source_case"] = source_case.strip()
3264 if source.get(
"velocity_scale")
is not None:
3265 velocity_scale =
_to_float(source.get(
"velocity_scale"), f
"{field_name}.velocity_scale")
3266 if velocity_scale <= 0.0:
3267 raise ValueError(f
"{field_name}.velocity_scale must be positive.")
3268 normalized[
"velocity_scale"] = velocity_scale
3269 if source.get(
"source_block")
is not None:
3271 source_block = int(source.get(
"source_block"))
3272 except (TypeError, ValueError):
3273 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
3274 if source_block < 0:
3275 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
3276 normalized[
"source_block"] = source_block
3277 if source.get(
"output_file")
is not None:
3278 output_file = source.get(
"output_file")
3279 if not isinstance(output_file, str)
or not output_file.strip():
3280 raise ValueError(f
"{field_name}.output_file must be a non-empty path when provided.")
3281 normalized[
"output_file"] = output_file.strip()
3286 @brief Validate the field_slice slice selector.
3287 @param[in] slice_cfg Slice selector mapping.
3288 @param[in] field_name Human-readable YAML path for diagnostics.
3289 @return Normalized selector mapping.
3291 if not isinstance(slice_cfg, dict):
3292 raise ValueError(f
"{field_name} must be a mapping.")
3293 orientation = str(slice_cfg.get(
"orientation",
"opposite")).strip().lower()
3294 if orientation
not in {
"opposite",
"same"}:
3295 raise ValueError(f
"{field_name}.orientation must be 'opposite' or 'same'.")
3296 normal_tolerance =
_to_float(slice_cfg.get(
"normal_tolerance", 0.99), f
"{field_name}.normal_tolerance")
3297 if normal_tolerance <= 0.0
or normal_tolerance > 1.0:
3298 raise ValueError(f
"{field_name}.normal_tolerance must be in the range (0, 1].")
3300 if slice_cfg.get(
"face")
is not None:
3301 unknown = sorted(set(slice_cfg.keys()) - {
"face",
"orientation",
"normal_tolerance"})
3304 f
"Unknown keys in {field_name}: {unknown}. "
3305 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
3307 face = str(slice_cfg.get(
"face",
"")).strip()
3308 if face.lower()
not in BC_FACE_MAP:
3309 raise ValueError(f
"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
3311 "face": BC_FACE_MAP[face.lower()],
3312 "orientation": orientation,
3313 "normal_tolerance": normal_tolerance,
3316 required = {
"axis",
"index",
"normal"}
3317 missing = sorted(key
for key
in required
if slice_cfg.get(key)
is None)
3319 raise ValueError(f
"{field_name} requires either face or axis/index/normal; missing {missing}.")
3320 unknown = sorted(set(slice_cfg.keys()) - {
"axis",
"index",
"normal",
"orientation",
"normal_tolerance"})
3323 f
"Unknown keys in {field_name}: {unknown}. "
3324 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
3326 axis = str(slice_cfg.get(
"axis",
"")).strip()
3327 axis_map = {
"xi":
"Xi",
"eta":
"Eta",
"zeta":
"Zeta"}
3328 if axis.lower()
not in axis_map:
3329 raise ValueError(f
"{field_name}.axis must be one of Xi, Eta, Zeta.")
3330 normal = str(slice_cfg.get(
"normal",
"")).strip()
3331 if normal.lower()
not in BC_FACE_MAP:
3332 raise ValueError(f
"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
3333 normal = BC_FACE_MAP[normal.lower()]
3334 if normal[1:].lower() != axis.lower():
3335 raise ValueError(f
"{field_name}.normal must use the same axis as {field_name}.axis.")
3337 index = int(slice_cfg.get(
"index"))
3338 except (TypeError, ValueError):
3339 raise ValueError(f
"{field_name}.index must be an integer.")
3341 raise ValueError(f
"{field_name}.index must be non-negative.")
3343 "axis": axis_map[axis.lower()],
3346 "orientation": orientation,
3347 "normal_tolerance": normal_tolerance,
3351 target_grid: str =
None, target_block: int = 0,
3352 target_face: str =
None, script: str =
None,
3353 case_path: str =
None) -> dict:
3355 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
3356 @param[in] output_path Path to write.
3357 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
3358 @param[in] params Normalized generator params.
3359 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
3360 @param[in] target_block Target block index when `target_grid` is provided.
3361 @param[in] target_face Target inlet face when `target_grid` is provided.
3362 @param[in] script Optional profile.gen-compatible script override.
3363 @param[in] case_path Current case.yml path used to anchor relative script overrides.
3364 @return Summary dictionary.
3366 n1, n2 = tuple(dims)
3368 if not os.path.isfile(profilegen_script):
3369 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
3373 "square_duct_poiseuille",
3380 str(float(params[
"bulk_velocity"])),
3382 str(int(params[
"n_terms"])),
3389 str(int(target_block)),
3390 f
"--target-face={target_face}",
3392 result = subprocess.run(cmd, text=
True, capture_output=
True)
3393 if result.returncode != 0:
3394 details = (result.stderr
or result.stdout
or "").strip()
3395 raise ValueError(f
"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
3397 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
3398 except (IndexError, json.JSONDecodeError)
as exc:
3399 raise ValueError(f
"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
3400 summary[
"dims"] = tuple(summary[
"dims"])
3404 target_grid: str, target_face: str, target_block: int,
3405 case_path: str) -> dict:
3407 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
3408 @param[in] output_path Path to write.
3409 @param[in] expected_dims Expected PICSLICE dimensions.
3410 @param[in] source Normalized field_slice source mapping.
3411 @param[in] target_grid Target canonical PICGRID path.
3412 @param[in] target_face Target inlet face token.
3413 @param[in] target_block Target block index.
3414 @param[in] case_path Path to current case.yml for relative source resolution.
3415 @return Summary dictionary from profile.gen.
3417 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3422 if not os.path.isfile(profilegen_script):
3423 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
3424 n1, n2 = tuple(expected_dims)
3425 slice_cfg = source[
"slice"]
3439 str(int(source.get(
"source_block", 0))),
3441 str(int(target_block)),
3442 f
"--target-face={target_face}",
3444 slice_cfg[
"orientation"],
3445 "--normal-tolerance",
3446 str(float(slice_cfg[
"normal_tolerance"])),
3448 str(float(velocity_scale)),
3453 if "face" in slice_cfg:
3454 cmd.append(f
"--slice-face={slice_cfg['face']}")
3460 str(int(slice_cfg[
"index"])),
3461 f
"--slice-normal={slice_cfg['normal']}",
3463 result = subprocess.run(cmd, text=
True, capture_output=
True)
3464 if result.returncode != 0:
3465 details = (result.stderr
or result.stdout
or "").strip()
3466 raise ValueError(f
"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
3468 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
3469 except (IndexError, json.JSONDecodeError)
as exc:
3470 raise ValueError(f
"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
3471 summary[
"dims"] = tuple(summary[
"dims"])
3476 @brief Resolve a path relative to the current case directory.
3477 @param[in] path_value Path from case.yml.
3478 @param[in] case_dir Current case directory.
3479 @return Absolute path.
3481 if not isinstance(path_value, str)
or not path_value.strip():
3482 raise ValueError(
"path value must be a non-empty string.")
3483 if os.path.isabs(path_value):
3484 return os.path.abspath(path_value)
3485 return os.path.abspath(os.path.join(case_dir, path_value))
3489 @brief Resolve field_slice dimensional velocity scale.
3490 @param[in] source Normalized field_slice source mapping.
3491 @param[in] case_dir Current case directory.
3492 @return Positive velocity scale.
3494 if source.get(
"velocity_scale")
is not None:
3495 return float(source[
"velocity_scale"])
3500 source_case_cfg.get(
"properties", {}).get(
"scaling", {}).get(
"velocity_ref"),
3501 "source_case.properties.scaling.velocity_ref",
3503 except AttributeError
as exc:
3504 raise ValueError(
"source_case must contain properties.scaling.velocity_ref.")
from exc
3505 if velocity_scale <= 0.0:
3506 raise ValueError(
"source_case.properties.scaling.velocity_ref must be positive.")
3507 return velocity_scale
3511 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
3512 @param[in] case_cfg Parsed current case config.
3513 @param[in] case_path Current case.yml path.
3514 @param[in] run_dir Current run/precompute directory.
3515 @return Absolute target PICGRID path.
3517 grid_cfg = case_cfg.get(
"grid", {})
or {}
3518 grid_mode = grid_cfg.get(
"mode")
3519 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3520 if grid_mode ==
"file":
3521 source_grid = grid_cfg.get(
"source_file")
3522 if not isinstance(source_grid, str)
or not source_grid.strip():
3523 raise ValueError(
"grid.source_file is required for field_slice target-grid normals.")
3525 if isinstance(grid_cfg.get(
"legacy_conversion"), dict)
and run_dir:
3528 if grid_mode ==
"grid_gen":
3529 generator = grid_cfg.get(
"generator", {})
3530 output_file = generator.get(
"output_file", os.path.join(
"config",
"grid.generated.picgrid"))
3531 candidate = output_file
if os.path.isabs(output_file)
else os.path.abspath(os.path.join(run_dir, output_file))
3532 if os.path.isfile(candidate):
3534 staged = os.path.join(run_dir,
"config",
"grid.run")
3535 if os.path.isfile(staged):
3537 raise ValueError(
"field_slice requires the generated target PICGRID to exist before profile extraction.")
3539 f
"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
3544 @brief Resolve an optional target canonical PICGRID for generated profile sampling.
3545 @param[in] case_cfg Parsed current case config.
3546 @param[in] case_path Current case.yml path.
3547 @param[in] run_dir Current run/precompute directory.
3548 @return Absolute target PICGRID path, or None when no canonical grid is available yet.
3550 grid_mode = (case_cfg.get(
"grid", {})
or {}).get(
"mode")
3551 if grid_mode ==
"programmatic_c":
3557 @brief Write a profile.info summary for generated inlet profiles.
3558 @param[in] config_dir Run/precompute config directory.
3559 @param[in] summaries Generated profile summaries.
3560 @return Path to profile.info.
3562 info_path = os.path.join(config_dir,
"profile.info")
3563 os.makedirs(config_dir, exist_ok=
True)
3564 with open(info_path,
"w")
as fout:
3565 fout.write(
"# PICurv generated profile summary\n")
3566 fout.write(f
"profile_count = {len(summaries)}\n\n")
3567 for idx, summary
in enumerate(summaries):
3568 dims = summary.get(
"dims", (0, 0))
3569 fout.write(f
"[profile_{idx}]\n")
3570 fout.write(f
"generator = {summary.get('generator')}\n")
3571 fout.write(f
"block = {summary.get('block')}\n")
3572 fout.write(f
"face = {summary.get('face')}\n")
3573 fout.write(f
"dimensions = {dims[0]} {dims[1]}\n")
3574 if summary.get(
"bulk_velocity")
is not None:
3575 fout.write(f
"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
3576 if summary.get(
"n_terms")
is not None:
3577 fout.write(f
"n_terms = {summary.get('n_terms')}\n")
3578 fout.write(f
"mean_speed = {summary.get('mean_speed'):.16e}\n")
3579 if "area_mean_speed" in summary:
3580 fout.write(f
"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
3581 if "discrete_mean_speed" in summary:
3582 fout.write(f
"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
3583 fout.write(f
"min_speed = {summary.get('min_speed'):.16e}\n")
3584 fout.write(f
"max_speed = {summary.get('max_speed'):.16e}\n")
3585 if summary.get(
"umax_over_ubulk")
is not None:
3586 fout.write(f
"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
3590 "area_weighted_mean_before_normalization",
3591 "area_weighted_mean_after_normalization",
3608 fout.write(f
"{key} = {summary.get(key)}\n")
3609 fout.write(f
"output_file = {summary.get('path')}\n\n")
3614 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
3615 @param[in] case_path Path to case.yml (used for relative path resolution).
3616 @param[in] run_dir Run directory path.
3617 @param[in] grid_cfg The grid config section from case.yml.
3618 @return Absolute path to generated dimensional PICGRID file.
3619 @throws ValueError on invalid config or generator failure.
3621 generator = grid_cfg.get(
"generator", {})
3622 if not isinstance(generator, dict):
3623 raise ValueError(
"grid.generator must be a mapping when grid.mode is 'grid_gen'.")
3625 case_dir = os.path.dirname(os.path.abspath(case_path))
3626 gridgen_script = generator.get(
"script", os.path.join(GENERATORS_PATH,
"grid.gen"))
3627 if not os.path.isabs(gridgen_script):
3628 gridgen_script = os.path.abspath(os.path.join(case_dir, gridgen_script))
3629 if not os.path.isfile(gridgen_script):
3630 raise ValueError(f
"grid.gen script not found: {gridgen_script}")
3632 config_file = generator.get(
"config_file")
3634 raise ValueError(
"grid.generator.config_file is required when grid.mode is 'grid_gen'.")
3635 if not os.path.isabs(config_file):
3636 config_file = os.path.abspath(os.path.join(case_dir, config_file))
3637 if not os.path.isfile(config_file):
3638 raise ValueError(f
"grid.generator.config_file not found: {config_file}")
3640 output_file = generator.get(
"output_file", os.path.join(
"config",
"grid.generated.picgrid"))
3641 if not os.path.isabs(output_file):
3642 output_file = os.path.abspath(os.path.join(run_dir, output_file))
3643 os.makedirs(os.path.dirname(output_file), exist_ok=
True)
3645 grid_type = generator.get(
"grid_type")
3646 cli_args = generator.get(
"cli_args", [])
3647 if cli_args
is None:
3649 if not isinstance(cli_args, list):
3650 raise ValueError(
"grid.generator.cli_args must be a list of CLI tokens.")
3652 cmd = [sys.executable, gridgen_script,
"-c", config_file]
3654 cmd.append(str(grid_type))
3655 cmd.extend([str(token)
for token
in cli_args])
3656 cmd.extend([
"--output", output_file])
3658 vts_file = generator.get(
"vts_file")
3660 if not os.path.isabs(vts_file):
3661 vts_file = os.path.abspath(os.path.join(run_dir, vts_file))
3662 os.makedirs(os.path.dirname(vts_file), exist_ok=
True)
3663 cmd.extend([
"--vts", vts_file])
3665 stats_file = generator.get(
"stats_file")
3667 if not os.path.isabs(stats_file):
3668 stats_file = os.path.abspath(os.path.join(run_dir, stats_file))
3669 os.makedirs(os.path.dirname(stats_file), exist_ok=
True)
3670 cmd.extend([
"--stats-file", stats_file])
3672 print(f
"[INFO] Grid generator command: {' '.join(cmd)}")
3673 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
3674 if result.returncode != 0:
3675 stderr = (result.stderr
or "").strip()
3676 stdout = (result.stdout
or "").strip()
3677 details = stderr
if stderr
else stdout
3679 f
"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
3682 print(result.stdout.strip())
3684 print(result.stderr.strip())
3686 if not os.path.isfile(output_file):
3687 raise ValueError(f
"grid.gen did not produce expected output file: {output_file}")
3694 @brief Optionally convert a legacy file-grid payload to canonical PICGRID using grid.gen.
3695 @details Activated only when `grid.legacy_conversion.enabled` is true in case.yml.
3696 The converted output remains dimensional; standard nondimensionalization still
3697 occurs via validate_and_nondimensionalize_picgrid().
3698 @param[in] case_path Path to case.yml (for relative path resolution).
3699 @param[in] run_dir Current run directory.
3700 @param[in] grid_cfg Grid section from case.yml.
3701 @param[in] source_grid Absolute or relative path to the original grid file.
3702 @return Grid path that should be fed into validate_and_nondimensionalize_picgrid().
3703 @throws ValueError on invalid converter settings or failed conversion.
3705 legacy_cfg = grid_cfg.get(
"legacy_conversion")
3706 if not isinstance(legacy_cfg, dict):
3709 enabled = legacy_cfg.get(
"enabled",
True)
3710 if enabled
is False:
3712 if not isinstance(enabled, bool):
3713 raise ValueError(
"grid.legacy_conversion.enabled must be a boolean.")
3715 raw_format = str(legacy_cfg.get(
"format",
"legacy1d")).strip().lower()
3717 "legacy1d":
"legacy1d",
3718 "legacy_1d":
"legacy1d",
3719 "les_flat_1d":
"legacy1d",
3720 "les-flat-1d":
"legacy1d",
3722 command = format_aliases.get(raw_format)
3725 "grid.legacy_conversion.format must be one of "
3726 "['legacy1d', 'legacy_1d', 'les_flat_1d', 'les-flat-1d']."
3729 case_dir = os.path.dirname(os.path.abspath(case_path))
3730 gridgen_script = legacy_cfg.get(
"script", os.path.join(GENERATORS_PATH,
"grid.gen"))
3731 if not isinstance(gridgen_script, str)
or not gridgen_script.strip():
3732 raise ValueError(
"grid.legacy_conversion.script must be a non-empty string when provided.")
3733 if not os.path.isabs(gridgen_script):
3734 gridgen_script = os.path.abspath(os.path.join(case_dir, gridgen_script))
3735 if not os.path.isfile(gridgen_script):
3736 raise ValueError(f
"grid.legacy_conversion.script not found: {gridgen_script}")
3738 output_file = legacy_cfg.get(
"output_file", os.path.join(
"config",
"grid.converted.picgrid"))
3739 if not isinstance(output_file, str)
or not output_file.strip():
3740 raise ValueError(
"grid.legacy_conversion.output_file must be a non-empty string when provided.")
3741 if not os.path.isabs(output_file):
3742 output_file = os.path.abspath(os.path.join(run_dir, output_file))
3743 os.makedirs(os.path.dirname(output_file), exist_ok=
True)
3745 axis_columns = legacy_cfg.get(
"axis_columns", [0, 1, 2])
3746 if not isinstance(axis_columns, list)
or len(axis_columns) != 3:
3747 raise ValueError(
"grid.legacy_conversion.axis_columns must be a 3-item list of non-negative integers.")
3749 axis_columns = [int(v)
for v
in axis_columns]
3750 except (TypeError, ValueError)
as exc:
3751 raise ValueError(
"grid.legacy_conversion.axis_columns must contain integers.")
from exc
3752 if any(v < 0
for v
in axis_columns):
3753 raise ValueError(
"grid.legacy_conversion.axis_columns values must be >= 0.")
3755 strict_trailing = legacy_cfg.get(
"strict_trailing",
True)
3756 if not isinstance(strict_trailing, bool):
3757 raise ValueError(
"grid.legacy_conversion.strict_trailing must be a boolean.")
3759 cli_args = legacy_cfg.get(
"cli_args", [])
3760 if cli_args
is None:
3762 if not isinstance(cli_args, list):
3763 raise ValueError(
"grid.legacy_conversion.cli_args must be a list of CLI tokens.")
3774 str(axis_columns[0]),
3775 str(axis_columns[1]),
3776 str(axis_columns[2]),
3780 cmd.append(
"--strict-trailing")
3782 cmd.append(
"--allow-trailing")
3783 cmd.extend(str(token)
for token
in cli_args)
3785 print(f
"[INFO] Legacy grid conversion command: {' '.join(cmd)}")
3786 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
3787 if result.returncode != 0:
3788 stderr = (result.stderr
or "").strip()
3789 stdout = (result.stdout
or "").strip()
3790 details = stderr
if stderr
else stdout
3792 f
"legacy grid conversion failed with exit code {result.returncode}. Details:\n{details}"
3795 print(result.stdout.strip())
3797 print(result.stderr.strip())
3798 if not os.path.isfile(output_file):
3799 raise ValueError(f
"legacy grid conversion did not produce expected output file: {output_file}")
3815 "symmetry":
"SYMMETRY",
3818 "periodic":
"PERIODIC",
3825 "required_params": set(),
3826 "optional_params": set(),
3828 "constant_velocity": {
3830 "required_params": {
"vx",
"vy",
"vz"},
3831 "optional_params": set(),
3834 "types": {
"OUTLET"},
3835 "required_params": set(),
3836 "optional_params": set(),
3840 "required_params": {
"v_max"},
3841 "optional_params": set(),
3843 "prescribed_flow": {
3845 "required_params": {
"source"},
3846 "optional_params": set(),
3849 "types": {
"PERIODIC"},
3850 "required_params": set(),
3851 "optional_params": set(),
3854 "types": {
"PERIODIC"},
3855 "required_params": {
"target_flux"},
3856 "optional_params": {
"apply_trim"},
3860_NUMERIC_BC_PARAMS = {
"vx",
"vy",
"vz",
"v_max",
"target_flux"}
3861_BOOL_BC_PARAMS = {
"apply_trim"}
3865 @brief Validate the structured source block for prescribed_flow BCs.
3866 @param[in] source Source mapping from case.yml.
3867 @param[in] field_name Human-readable YAML path for diagnostics.
3868 @return Normalized source mapping.
3869 @throws ValueError on invalid source contract.
3871 if not isinstance(source, dict):
3872 raise ValueError(f
"{field_name} must be a mapping with type: file, generated, or field_slice.")
3873 source_type = str(source.get(
"type",
"")).strip().lower()
3874 if source_type ==
"file":
3875 path = source.get(
"path")
3876 if not isinstance(path, str)
or not path.strip():
3877 raise ValueError(f
"{field_name}.path must be a non-empty file path.")
3878 unknown = sorted(set(source.keys()) - {
"type",
"path"})
3880 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
3881 return {
"type":
"file",
"path": path.strip()}
3883 if source_type ==
"generated":
3884 generator = str(source.get(
"generator",
"")).strip().lower()
3885 if generator
not in GENERATED_PROFILE_GENERATORS:
3887 f
"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
3888 f
"(got '{source.get('generator')}')."
3890 unknown = sorted(set(source.keys()) - {
"type",
"generator",
"script",
"output_file",
"params"})
3893 f
"Unknown keys in {field_name}: {unknown}. "
3894 "Allowed: ['generator', 'output_file', 'params', 'script', 'type']."
3897 "type":
"generated",
3898 "generator": generator,
3901 output_file = source.get(
"output_file")
3902 if output_file
is not None:
3903 if not isinstance(output_file, str)
or not output_file.strip():
3904 raise ValueError(f
"{field_name}.output_file must be a non-empty path when provided.")
3905 normalized[
"output_file"] = output_file.strip()
3906 script = source.get(
"script")
3907 if script
is not None:
3908 if not isinstance(script, str)
or not script.strip():
3909 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
3910 normalized[
"script"] = script.strip()
3913 if source_type ==
"field_slice":
3916 raise ValueError(f
"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
3920 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
3921 @param[in] face Canonical BC face token.
3922 @param[in] block_dims (IM, JM, KM) node counts.
3923 @return (n1, n2) dimensions in profile storage order.
3925 im, jm, km = block_dims
3926 if min(im, jm, km) < 2:
3928 f
"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
3930 if face
in {
"-Xi",
"+Xi"}:
3931 return (km - 1, jm - 1)
3932 if face
in {
"-Eta",
"+Eta"}:
3933 return (km - 1, im - 1)
3934 if face
in {
"-Zeta",
"+Zeta"}:
3935 return (jm - 1, im - 1)
3936 raise ValueError(f
"Unsupported face '{face}' for prescribed_flow profile dimensions.")
3940 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
3941 @param[in] case_cfg Parsed case.yml configuration.
3942 @param[in] case_path Path to case.yml for relative path resolution.
3943 @param[in] run_dir Current run directory, used for optional generated grid outputs.
3944 @return List of (IM, JM, KM) node-count tuples.
3945 @throws ValueError when dimensions cannot be resolved.
3947 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
3948 grid_cfg = case_cfg.get(
"grid", {})
3949 grid_mode = grid_cfg.get(
"mode")
3950 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3952 if grid_mode ==
"programmatic_c":
3953 settings = grid_cfg.get(
"programmatic_settings", {})
3955 for key
in (
"im",
"jm",
"km"):
3956 raw = settings.get(key)
3958 raise ValueError(f
"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
3959 if isinstance(raw, list):
3960 if len(raw) != num_blocks:
3962 f
"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
3966 values = [raw] * num_blocks
3968 values = [int(v) + 1
for v
in values]
3969 except (TypeError, ValueError):
3970 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
3971 if any(v <= 1
for v
in values):
3972 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
3973 dims_by_axis.append(values)
3974 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
3976 if grid_mode ==
"file":
3977 source_grid = grid_cfg.get(
"source_file")
3978 if not isinstance(source_grid, str)
or not source_grid.strip():
3979 raise ValueError(
"grid.source_file is required for file-grid prescribed_flow profile validation.")
3980 if not os.path.isabs(source_grid):
3981 source_grid = os.path.abspath(os.path.join(case_dir, source_grid))
3982 if isinstance(grid_cfg.get(
"legacy_conversion"), dict)
and run_dir:
3986 if grid_mode ==
"grid_gen":
3987 generator = grid_cfg.get(
"generator", {})
3988 output_file = generator.get(
"output_file", os.path.join(
"config",
"grid.generated.picgrid"))
3991 candidates.append(output_file
if os.path.isabs(output_file)
else os.path.abspath(os.path.join(run_dir, output_file)))
3992 candidates.append(os.path.join(run_dir,
"config",
"grid.run"))
3993 for candidate
in candidates:
3994 if os.path.isfile(candidate):
3997 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
3998 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
4001 raise ValueError(f
"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
4004 profile_grid_dims: list =
None) -> list:
4006 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
4007 @param[in] run_dir Run/precompute directory root.
4008 @param[in] case_cfg Parsed case.yml.
4009 @param[in] case_path Path to case.yml for relative grid/source resolution.
4010 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
4011 @return List of generated profile summaries.
4015 bc.get(
"handler") ==
"prescribed_flow"
4016 and ((bc.get(
"params")
or {}).get(
"source")
or {}).get(
"type")
in {
"generated",
"field_slice"}
4017 for block
in prepared_blocks
for bc
in block
4020 if profile_grid_dims
is None:
4023 config_dir = os.path.join(run_dir,
"config")
4025 generated_target_grid =
None
4027 for block_idx, block
in enumerate(prepared_blocks):
4029 if bc.get(
"handler") !=
"prescribed_flow":
4031 source = (bc.get(
"params")
or {}).get(
"source", {})
4032 if source.get(
"type")
not in {
"generated",
"field_slice"}:
4037 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
4038 default_output = os.path.join(
4039 "config", f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.picslice"
4043 source.get(
"output_file"),
4045 default_to_config_dir=
True,
4047 if source.get(
"type") ==
"generated" and source[
"generator"] ==
"square_duct_poiseuille":
4048 if generated_target_grid
is None:
4054 target_grid=generated_target_grid,
4055 target_block=block_idx,
4057 script=source.get(
"script"),
4058 case_path=case_path,
4060 elif source.get(
"type") ==
"generated":
4061 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
4063 if target_grid
is None:
4074 summary.update({
"block": block_idx,
"face": face})
4075 summaries.append(summary)
4077 f
"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
4078 f
"{os.path.relpath(output_path)} dims={summary['dims']}"
4083 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
4088 @brief Convert a YAML scalar to float with a clear error message.
4089 @param[in] value Argument passed to `_to_float()`.
4090 @param[in] field_name Argument passed to `_to_float()`.
4091 @return Value returned by `_to_float()`.
4095 except (TypeError, ValueError):
4096 raise ValueError(f
"'{field_name}' must be numeric (got {value!r}).")
4100 @brief Convert a YAML scalar/string to bool with a clear error message.
4101 @param[in] value Argument passed to `_to_bool()`.
4102 @param[in] field_name Argument passed to `_to_bool()`.
4103 @return Value returned by `_to_bool()`.
4105 if isinstance(value, bool):
4107 if isinstance(value, str):
4108 raw = value.strip().lower()
4109 if raw
in {
"true",
"1",
"yes"}:
4111 if raw
in {
"false",
"0",
"no"}:
4113 raise ValueError(f
"'{field_name}' must be boolean (got {value!r}).")
4117 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
4118 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
4119 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
4120 @return Value returned by `normalize_boundary_conditions_layout()`.
4122 if not all_blocks_bcs:
4123 raise ValueError(
"The 'boundary_conditions' section in case.yml is empty.")
4125 is_simple_list = isinstance(all_blocks_bcs[0], dict)
4126 if num_blocks == 1
and is_simple_list:
4127 all_blocks_bcs = [all_blocks_bcs]
4128 elif is_simple_list
and num_blocks > 1:
4130 f
"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
4131 "Use a list-of-lists, one inner list per block."
4134 if len(all_blocks_bcs) != num_blocks:
4136 f
"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
4138 return all_blocks_bcs
4142 @brief Validate BC entries against currently supported C-side handlers/types and
4143 @details return normalized entries ready for bcs.run generation.
4144 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
4145 @return Value returned by `validate_and_prepare_boundary_conditions()`.
4147 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
4148 scales = case_cfg.get(
'properties', {}).get(
'scaling', {})
4149 L_ref =
_to_float(scales.get(
'length_ref'),
"properties.scaling.length_ref")
4150 U_ref =
_to_float(scales.get(
'velocity_ref'),
"properties.scaling.velocity_ref")
4152 raise ValueError(
"properties.scaling.velocity_ref must be non-zero for non-dimensionalization.")
4154 raise ValueError(
"properties.scaling.length_ref must be non-zero for non-dimensionalization.")
4157 prepared_blocks = []
4159 expected_faces = {
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta"}
4160 axis_pairs = [(
"-Xi",
"+Xi"), (
"-Eta",
"+Eta"), (
"-Zeta",
"+Zeta")]
4162 for bi, block_bcs
in enumerate(all_blocks_bcs):
4163 if not isinstance(block_bcs, list):
4164 raise ValueError(f
"boundary_conditions[{bi}] must be a list of face configs.")
4169 for idx, bc
in enumerate(block_bcs):
4170 if not isinstance(bc, dict):
4171 raise ValueError(f
"boundary_conditions[{bi}][{idx}] must be a mapping.")
4173 for req
in (
"face",
"type",
"handler"):
4175 raise ValueError(f
"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
4177 face_raw = str(bc[
"face"]).strip()
4178 face_key = face_raw.lower()
4179 face = BC_FACE_MAP.get(face_key)
4182 f
"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
4183 f
"Supported: {sorted(expected_faces)}."
4185 if face
in seen_faces:
4186 raise ValueError(f
"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
4187 seen_faces[face] = idx
4189 bc_type_raw = str(bc[
"type"]).strip()
4190 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
4193 f
"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
4194 f
"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
4197 handler = str(bc[
"handler"]).strip().lower()
4198 handler_spec = BC_HANDLER_SPECS.get(handler)
4199 if handler_spec
is None:
4201 f
"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
4202 f
"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
4204 if bc_type
not in handler_spec[
"types"]:
4206 f
"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
4209 params = bc.get(
"params", {})
4212 if not isinstance(params, dict):
4213 raise ValueError(f
"'params' for block {bi}, face {face} must be a mapping.")
4216 if "vector" in params
or "velocity" in params:
4218 f
"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
4219 "Use scalar keys 'vx', 'vy', 'vz'."
4222 required = handler_spec[
"required_params"]
4223 optional = handler_spec[
"optional_params"]
4224 allowed = required | optional
4226 missing = sorted(required - set(params.keys()))
4229 f
"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
4231 unknown = sorted(set(params.keys()) - allowed)
4234 f
"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
4235 f
"Allowed: {sorted(allowed)}."
4238 converted_params = {}
4239 for key, value
in params.items():
4240 if key
in _NUMERIC_BC_PARAMS:
4241 numeric =
_to_float(value, f
"boundary_conditions[{bi}][{idx}].params.{key}")
4242 if key
in {
"vx",
"vy",
"vz",
"v_max"}:
4243 converted_params[key] = numeric / U_ref
4244 elif key ==
"target_flux":
4245 converted_params[key] = numeric / (U_ref * (L_ref ** 2))
4246 elif key
in _BOOL_BC_PARAMS:
4247 converted_params[key] =
_to_bool(value, f
"boundary_conditions[{bi}][{idx}].params.{key}")
4248 elif handler ==
"prescribed_flow" and key ==
"source":
4250 value, f
"boundary_conditions[{bi}][{idx}].params.source"
4254 converted_params[key] = value
4256 prepared_block.append({
4260 "params": converted_params,
4263 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
4266 f
"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
4267 "Provide all six faces explicitly."
4271 face_map = {entry[
"face"]: entry
for entry
in prepared_block}
4272 for neg_face, pos_face
in axis_pairs:
4273 neg = face_map[neg_face]
4274 pos = face_map[pos_face]
4275 neg_periodic = (neg[
"type"] ==
"PERIODIC")
4276 pos_periodic = (pos[
"type"] ==
"PERIODIC")
4277 if neg_periodic != pos_periodic:
4279 f
"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
4282 driven_handlers = {
"constant_flux"}
4283 if (neg[
"handler"]
in driven_handlers)
or (pos[
"handler"]
in driven_handlers):
4284 if neg[
"handler"] != pos[
"handler"]:
4286 f
"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
4288 if not (neg_periodic
and pos_periodic):
4290 f
"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
4293 prepared_blocks.append(prepared_block)
4295 return prepared_blocks
4300 @brief Render an internal schema path tuple as a user-facing YAML path.
4301 @param[in] path Internal path tuple.
4302 @return Dotted YAML path.
4304 return ".".join(part
for part
in path
if part !=
"[]")
or "<root>"
4309 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
4310 @param[in] schema Role schema mapping.
4311 @param[in] path Internal path tuple.
4312 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
4316 for idx, part
in enumerate(path):
4319 candidate = path[:idx] + (
"*",) + path[idx + 1:]
4320 if candidate
in schema:
4321 return schema[candidate]
4327 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
4328 @param[in] schema Role schema mapping.
4329 @param[in] path Current internal YAML path tuple.
4330 @param[in] key Unsupported YAML key.
4331 @param[in] allowed Allowed keys at the current path.
4332 @return Optional hint string.
4335 allowed_strings = sorted(str(item)
for item
in allowed)
4336 lower_matches = [item
for item
in allowed_strings
if item.lower() == key.lower()]
4337 close_matches = lower_matches
or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
4339 hints.append(f
"Did you mean '{close_matches[0]}'?")
4342 for schema_path, schema_allowed
in schema.items():
4343 if schema_path == path
or not schema_allowed:
4345 if key
in schema_allowed:
4348 hints.append(f
"This key is valid at: {', '.join(sorted(valid_paths))}.")
4350 return " ".join(hints)
4355 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
4356 @param[in] cfg Parsed YAML node.
4357 @param[in] schema Role schema mapping.
4358 @param[in] file_path Source file path for diagnostics.
4359 @param[in,out] errors Validation error accumulator.
4360 @param[in] path Current internal YAML path tuple.
4362 if isinstance(cfg, dict):
4364 if allowed
is not False and allowed
is not None:
4365 unknown = sorted(str(key)
for key
in cfg.keys()
if key
not in allowed)
4368 hint_text = f
" {hint}" if hint
else ""
4370 f
" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
4371 f
"Allowed keys: {sorted(allowed)}.{hint_text}"
4375 for key, value
in cfg.items():
4377 elif isinstance(cfg, list):
4384 "properties",
"run_control",
"grid",
"models",
"boundary_conditions",
"solver_parameters",
4386 (
"run_control",): {
"start_step",
"total_steps",
"dt_physical"},
4387 (
"properties",): {
"scaling",
"fluid",
"initial_conditions"},
4388 (
"properties",
"scaling"): {
"length_ref",
"velocity_ref"},
4389 (
"properties",
"fluid"): {
"density",
"viscosity"},
4390 (
"properties",
"initial_conditions"): {
4391 "mode",
"generator",
"params",
"field",
"source_file",
4392 "u_physical",
"v_physical",
"w_physical",
"peak_velocity_physical",
4393 "velocity_physical",
"flow_direction",
4395 (
"properties",
"initial_conditions",
"params"):
None,
4397 "mode",
"source_file",
"programmatic_settings",
"generator",
"legacy_conversion",
4398 "da_processors_x",
"da_processors_y",
"da_processors_z",
4400 (
"grid",
"programmatic_settings"): {
4401 "im",
"jm",
"km",
"xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
4402 "rxs",
"rys",
"rzs",
"cgrids",
4403 "da_processors_x",
"da_processors_y",
"da_processors_z",
4405 (
"grid",
"generator"): {
4406 "script",
"config_file",
"grid_type",
"cli_args",
"output_file",
"stats_file",
"vts_file",
4408 "config-file",
"grid-type",
"output-file",
"stats-file",
"vts-file",
4410 (
"grid",
"legacy_conversion"): {
4411 "enabled",
"format",
"script",
"output_file",
"axis_columns",
"strict_trailing",
"cli_args",
4413 (
"models",): {
"domain",
"physics",
"statistics"},
4414 (
"models",
"domain"): {
"blocks"},
4415 (
"models",
"physics"): {
"dimensionality",
"fsi",
"particles",
"turbulence"},
4416 (
"models",
"physics",
"fsi"): {
"immersed",
"moving_fsi"},
4417 (
"models",
"physics",
"particles"): {
"count",
"init_mode",
"restart_mode",
"point_source"},
4418 (
"models",
"physics",
"particles",
"point_source"): {
"x",
"y",
"z"},
4419 (
"models",
"physics",
"turbulence"): {
"les",
"rans",
"wall_function"},
4420 (
"models",
"physics",
"turbulence",
"les"): {
4421 "enabled",
"model",
"constant_cs",
"max_cs",
"dynamic_frequency",
"test_filter",
4423 (
"models",
"physics",
"turbulence",
"rans"): {
"enabled",
"model"},
4424 (
"models",
"physics",
"turbulence",
"wall_function"): {
"enabled",
"model",
"roughness_height"},
4425 (
"models",
"statistics"): {
"time_averaging"},
4426 (
"boundary_conditions",
"[]"): {
"face",
"type",
"handler",
"params"},
4427 (
"boundary_conditions",
"[]",
"[]"): {
"face",
"type",
"handler",
"params"},
4428 (
"boundary_conditions",
"[]",
"params"):
None,
4429 (
"boundary_conditions",
"[]",
"[]",
"params"):
None,
4430 (
"solver_parameters",):
None,
4436 "operation_mode",
"strategy",
"tolerances",
"momentum_solver",
"poisson_solver",
4437 "pressure_solver",
"interpolation",
"petsc_passthrough_options",
"verification",
4438 "scalar_transport",
"solution_convergence",
4440 (
"operation_mode",): {
"eulerian_field_source",
"analytical_type",
"uniform_flow"},
4441 (
"operation_mode",
"uniform_flow"): {
"u",
"v",
"w"},
4442 (
"strategy",): {
"momentum_solver",
"central_diff"},
4444 "max_iterations",
"absolute_tol",
"relative_tol",
"step_tol",
4445 "residual_absolute_tol",
"residual_relative_tol",
4447 (
"momentum_solver",): {
4448 "type",
"dual_time_picard_jameson_rk",
"dual_time_picard_rk4",
"newton_krylov",
4450 (
"momentum_solver",
"dual_time_picard_jameson_rk"): {
4451 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
4452 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
4455 (
"momentum_solver",
"dual_time_picard_jameson_rk",
"pseudo_cfl"): {
4456 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
4458 (
"momentum_solver",
"dual_time_picard_rk4"): {
4459 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
4460 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
4463 (
"momentum_solver",
"dual_time_picard_rk4",
"pseudo_cfl"): {
4464 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
4466 (
"momentum_solver",
"newton_krylov"): {
"nonlinear_solver",
"linear_solver"},
4467 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver"): {
4468 "method",
"absolute_tolerance",
"relative_tolerance",
"step_tolerance",
4469 "max_iterations",
"line_search",
4471 (
"momentum_solver",
"newton_krylov",
"nonlinear_solver",
"line_search"): {
"type"},
4472 (
"momentum_solver",
"newton_krylov",
"linear_solver"): {
4473 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
4474 "gmres",
"preconditioner",
4476 (
"momentum_solver",
"newton_krylov",
"linear_solver",
"gmres"): {
"restart"},
4477 (
"momentum_solver",
"newton_krylov",
"linear_solver",
"preconditioner"): {
"type"},
4478 (
"poisson_solver",): {
4479 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
4480 "gmres",
"preconditioner",
"multigrid",
4482 (
"pressure_solver",): {
4483 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
4484 "gmres",
"preconditioner",
"multigrid",
4486 (
"poisson_solver",
"gmres"): {
"restart"},
4487 (
"pressure_solver",
"gmres"): {
"restart"},
4488 (
"poisson_solver",
"preconditioner"): {
"type"},
4489 (
"pressure_solver",
"preconditioner"): {
"type"},
4490 (
"poisson_solver",
"multigrid"): {
4491 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
4493 (
"pressure_solver",
"multigrid"): {
4494 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
4496 (
"poisson_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
4497 (
"pressure_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
4498 (
"poisson_solver",
"multigrid",
"level_solvers",
"*"): {
4499 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
4501 (
"pressure_solver",
"multigrid",
"level_solvers",
"*"): {
4502 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
4504 (
"interpolation",): {
"method"},
4505 (
"petsc_passthrough_options",):
None,
4506 (
"verification",): {
"sources"},
4507 (
"verification",
"sources"): {
"diffusivity",
"scalar"},
4508 (
"verification",
"sources",
"diffusivity"): {
"mode",
"profile",
"gamma0",
"slope_x"},
4509 (
"verification",
"sources",
"scalar"): {
4510 "mode",
"profile",
"value",
"phi0",
"slope_x",
"amplitude",
"kx",
"ky",
"kz",
4512 (
"scalar_transport",): {
"schmidt_number",
"turbulent_schmidt_number"},
4513 (
"solution_convergence",): {
"enabled",
"mode",
"periodic_deterministic",
"statistical_steady"},
4514 (
"solution_convergence",
"periodic_deterministic"): {
"period_steps"},
4515 (
"solution_convergence",
"statistical_steady"): {
"window_steps"},
4520 (): {
"logging",
"profiling",
"diagnostics",
"io",
"solver_monitoring"},
4521 (
"logging",): {
"verbosity",
"enabled_functions"},
4522 (
"profiling",): {
"timestep_output",
"final_summary"},
4523 (
"profiling",
"timestep_output"): {
"mode",
"functions",
"file"},
4524 (
"profiling",
"final_summary"): {
"enabled"},
4525 (
"diagnostics",): {
"petsc",
"runtime_memory_log"},
4526 (
"diagnostics",
"petsc"): {
4527 "malloc_debug",
"malloc_test",
"malloc_dump",
"malloc_view",
"malloc_view_threshold",
4528 "memory_view",
"log_view",
"log_view_memory",
"log_all",
"log_trace",
4529 "objects_dump",
"options_left",
4531 (
"diagnostics",
"runtime_memory_log"): {
"enabled",
"file"},
4533 "data_output_frequency",
"particle_console_output_frequency",
"particle_log_interval",
4536 (
"io",
"directories"): {
"output",
"restart",
"log",
"eulerian_subdir",
"particle_subdir"},
4537 (
"solver_monitoring",): {
"momentum",
"poisson",
"petsc_passthrough_options"},
4538 (
"solver_monitoring",
"momentum"): {
4539 "newton_krylov_history",
"snes_monitor",
"snes_converged_reason",
4540 "ksp_monitor",
"ksp_converged_reason",
4542 (
"solver_monitoring",
"poisson"): {
"pic_true_residual",
"true_residual",
"converged_reason",
"view"},
4543 (
"solver_monitoring",
"petsc_passthrough_options"):
None,
4549 "run_control",
"source_data",
"global_operations",
"eulerian_pipeline",
4550 "lagrangian_pipeline",
"statistics_pipeline",
"statistics_output_prefix",
"io",
4553 "start_step",
"end_step",
"step_interval",
"startTime",
"endTime",
"timeStep",
4555 (
"source_data",): {
"directory",
"input_extensions"},
4556 (
"source_data",
"input_extensions"): {
"eulerian",
"particle"},
4557 (
"global_operations",): {
"dimensionalize"},
4558 (
"eulerian_pipeline",
"[]"): {
"task",
"input_field",
"output_field",
"field",
"reference_point"},
4559 (
"lagrangian_pipeline",
"[]"): {
"task",
"input_field",
"output_field"},
4560 (
"statistics_pipeline",): {
"output_prefix",
"tasks"},
4561 (
"statistics_pipeline",
"tasks",
"[]"): {
"task"},
4563 "output_directory",
"output_filename_prefix",
"particle_filename_prefix",
"output_particles",
4564 "particle_subsampling_frequency",
"input_extensions",
"eulerian_fields_averaged",
4565 "eulerian_fields",
"particle_fields",
4567 (
"io",
"input_extensions"): {
"eulerian",
"particle"},
4572 (): {
"scheduler",
"resources",
"notifications",
"execution"},
4573 (
"scheduler",): {
"type"},
4574 (
"resources",): {
"account",
"partition",
"nodes",
"ntasks_per_node",
"mem",
"time"},
4575 (
"notifications",): {
"mail_user",
"mail_type"},
4577 "module_setup",
"launcher",
"launcher_args",
"extra_sbatch",
"walltime_guard",
4579 (
"execution",
"extra_sbatch"):
None,
4580 (
"execution",
"walltime_guard"): {
4581 "enabled",
"warmup_steps",
"multiplier",
"min_seconds",
"estimator_alpha",
4588 "base_configs",
"study_type",
"parameters",
"parameter_sets",
"metrics",
"plotting",
"execution",
4590 (
"base_configs",): {
"case",
"solver",
"monitor",
"post"},
4591 (
"parameters",):
None,
4592 (
"parameter_sets",
"[]"):
None,
4593 (
"metrics",
"[]"): {
4594 "name",
"source",
"file_glob",
"column",
"reduction",
"normalize_by_parameter",
4595 "numerator_column",
"denominator_column",
"denominator_floor",
4597 (
"plotting",): {
"enabled",
"output_format"},
4598 (
"execution",): {
"max_concurrent_array_tasks"},
4603 case_path: str, solver_path: str, monitor_path: str):
4605 @brief Validates all solver input configs before any work is done.
4606 @details Checks for required sections, required keys, and physical sanity.
4607 Exits with a clear error message on the first problem found.
4608 @param[in] case_cfg Parsed case YAML dictionary.
4609 @param[in] solver_cfg Parsed solver YAML dictionary.
4610 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4611 @param[in] case_path Path to case file (for error messages).
4612 @param[in] solver_path Path to solver file (for error messages).
4613 @param[in] monitor_path Path to monitor file (for error messages).
4614 @throws SystemExit on validation failure.
4618 eulerian_source_mode =
"solve"
4625 required_case_sections = [
'properties',
'run_control',
'grid',
'models',
'boundary_conditions']
4626 for section
in required_case_sections:
4627 if section
not in case_cfg:
4628 errors.append(f
" {case_path}: missing required section '{section}'.")
4634 props = case_cfg.get(
'properties', {})
4635 for group, keys
in [(
'scaling', [
'length_ref',
'velocity_ref']),
4636 (
'fluid', [
'density',
'viscosity'])]:
4637 sub = props.get(group, {})
4639 errors.append(f
" {case_path}: missing 'properties.{group}' section.")
4643 errors.append(f
" {case_path}: missing key 'properties.{group}.{k}'.")
4646 rc = case_cfg.get(
'run_control', {})
4647 for k
in [
'start_step',
'total_steps',
'dt_physical']:
4649 errors.append(f
" {case_path}: missing key 'run_control.{k}'.")
4653 density = float(props.get(
'fluid', {}).get(
'density', 0))
4654 viscosity = float(props.get(
'fluid', {}).get(
'viscosity', 0))
4655 dt = float(rc.get(
'dt_physical', 0))
4657 errors.append(f
" {case_path}: 'properties.fluid.density' must be positive (got {density}).")
4659 errors.append(f
" {case_path}: 'properties.fluid.viscosity' must be non-negative (got {viscosity}).")
4661 errors.append(f
" {case_path}: 'run_control.dt_physical' must be positive (got {dt}).")
4662 except (TypeError, ValueError):
4666 grid_cfg = case_cfg.get(
'grid', {})
4667 grid_mode = grid_cfg.get(
'mode')
4668 valid_grid_modes = [
'file',
'programmatic_c',
'grid_gen']
4669 if grid_mode
not in valid_grid_modes:
4670 errors.append(f
" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
4671 elif grid_mode ==
'file':
4672 source_file = grid_cfg.get(
'source_file')
4674 errors.append(f
" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
4676 source_abs = source_file
if os.path.isabs(source_file)
else os.path.abspath(os.path.join(os.path.dirname(case_path), source_file))
4677 if not os.path.isfile(source_abs):
4678 errors.append(f
" {case_path}: grid.source_file does not exist: {source_abs}")
4680 legacy_cfg = grid_cfg.get(
"legacy_conversion")
4681 if legacy_cfg
is not None:
4682 if not isinstance(legacy_cfg, dict):
4683 errors.append(f
" {case_path}: grid.legacy_conversion must be a mapping when provided.")
4685 enabled = legacy_cfg.get(
"enabled",
True)
4686 if not isinstance(enabled, bool):
4687 errors.append(f
" {case_path}: grid.legacy_conversion.enabled must be a boolean.")
4689 fmt = legacy_cfg.get(
"format")
4691 normalized_fmt = str(fmt).strip().lower()
4692 allowed_formats = {
"legacy1d",
"legacy_1d",
"les_flat_1d",
"les-flat-1d"}
4693 if normalized_fmt
not in allowed_formats:
4695 f
" {case_path}: grid.legacy_conversion.format must be one of "
4696 f
"{sorted(allowed_formats)} (got '{fmt}')."
4699 script_path = legacy_cfg.get(
"script")
4700 if script_path
is not None:
4701 if not isinstance(script_path, str)
or not script_path.strip():
4702 errors.append(f
" {case_path}: grid.legacy_conversion.script must be a non-empty string.")
4704 script_abs = script_path
if os.path.isabs(script_path)
else os.path.abspath(os.path.join(os.path.dirname(case_path), script_path))
4705 if not os.path.isfile(script_abs):
4706 errors.append(f
" {case_path}: grid.legacy_conversion.script does not exist: {script_abs}")
4708 output_file = legacy_cfg.get(
"output_file")
4709 if output_file
is not None and (
not isinstance(output_file, str)
or not output_file.strip()):
4710 errors.append(f
" {case_path}: grid.legacy_conversion.output_file must be a non-empty string when provided.")
4712 axis_columns = legacy_cfg.get(
"axis_columns")
4713 if axis_columns
is not None:
4714 if not isinstance(axis_columns, list)
or len(axis_columns) != 3:
4715 errors.append(f
" {case_path}: grid.legacy_conversion.axis_columns must be a 3-item integer list.")
4717 for idx, value
in enumerate(axis_columns):
4718 if not isinstance(value, int)
or value < 0:
4720 f
" {case_path}: grid.legacy_conversion.axis_columns[{idx}] must be a non-negative integer (got {value})."
4723 strict_trailing = legacy_cfg.get(
"strict_trailing")
4724 if strict_trailing
is not None and not isinstance(strict_trailing, bool):
4725 errors.append(f
" {case_path}: grid.legacy_conversion.strict_trailing must be a boolean when provided.")
4727 cli_args = legacy_cfg.get(
"cli_args")
4728 if cli_args
is not None and not isinstance(cli_args, list):
4729 errors.append(f
" {case_path}: grid.legacy_conversion.cli_args must be a list of CLI tokens.")
4730 elif grid_mode ==
'programmatic_c':
4731 grid_settings = grid_cfg.get(
'programmatic_settings')
4732 if not grid_settings:
4733 errors.append(f
" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
4734 elif not isinstance(grid_settings, dict):
4735 errors.append(f
" {case_path}: 'grid.programmatic_settings' must be a mapping.")
4736 elif grid_mode ==
'grid_gen':
4737 gen_cfg = grid_cfg.get(
'generator')
4738 if not isinstance(gen_cfg, dict):
4739 errors.append(f
" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
4743 config_file = gen_cfg.get(
'config_file')
4745 errors.append(f
" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
4747 config_abs = config_file
if os.path.isabs(config_file)
else os.path.abspath(os.path.join(os.path.dirname(case_path), config_file))
4748 if not os.path.isfile(config_abs):
4749 errors.append(f
" {case_path}: grid.generator.config_file does not exist: {config_abs}")
4751 grid_type = gen_cfg.get(
'grid_type')
4752 if grid_type
is not None and str(grid_type)
not in {
'cpipe',
'pipe',
'warp'}:
4753 errors.append(f
" {case_path}: grid.generator.grid_type must be one of ['cpipe','pipe','warp'] (got '{grid_type}').")
4755 cli_args = gen_cfg.get(
'cli_args', [])
4756 if cli_args
is not None and not isinstance(cli_args, list):
4757 errors.append(f
" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
4760 except ValueError
as e:
4761 errors.append(f
" {case_path}: {e}")
4764 prepared_blocks =
None
4767 except ValueError
as e:
4768 errors.append(f
" {case_path}: {e}")
4771 ic = props.get(
'initial_conditions', {})
4774 errors.append(f
" {case_path}: missing 'properties.initial_conditions' section.")
4775 elif not isinstance(ic, dict):
4776 errors.append(f
" {case_path}: 'properties.initial_conditions' must be a mapping.")
4777 elif 'mode' not in ic:
4779 f
" {case_path}: missing key 'properties.initial_conditions.mode'. "
4780 "Specify 'generated' or 'file' explicitly."
4785 except KeyError
as e:
4786 errors.append(f
" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
4787 except ValueError
as e:
4788 errors.append(f
" {case_path}: {e}")
4789 if grid_mode ==
'programmatic_c' and resolved_ic
and resolved_ic.get(
"kind") ==
"ic_gen":
4792 except ValueError
as e:
4793 errors.append(f
" {case_path}: {e}")
4796 particles_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'particles', {})
4797 if particles_cfg
and not isinstance(particles_cfg, dict):
4798 errors.append(f
" {case_path}: 'models.physics.particles' must be a mapping.")
4799 elif isinstance(particles_cfg, dict):
4800 init_mode_raw = particles_cfg.get(
'init_mode',
'Surface')
4803 except ValueError
as e:
4804 errors.append(f
" {case_path}: {e}")
4807 restart_mode = particles_cfg.get(
'restart_mode')
4808 if restart_mode
is not None and str(restart_mode).lower()
not in {
"init",
"load"}:
4810 f
" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
4812 elif 'restart_mode' not in particles_cfg:
4814 start_step = int(rc.get(
'start_step', 0))
4815 particle_count = int(particles_cfg.get(
'count', 0)
or 0)
4816 except (TypeError, ValueError):
4819 if start_step > 0
and particle_count > 0:
4821 f
"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
4822 "(run_control.start_step > 0, count > 0). C will default to 'load'."
4826 point_cfg = particles_cfg.get(
'point_source', {})
4827 if not isinstance(point_cfg, dict):
4828 errors.append(f
" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
4830 for coord
in (
'x',
'y',
'z'):
4831 if coord
not in point_cfg:
4833 f
" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
4837 turbulence_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'turbulence', {})
4838 if turbulence_cfg
is not None and not isinstance(turbulence_cfg, dict):
4839 errors.append(f
" {case_path}: 'models.physics.turbulence' must be a mapping.")
4840 elif isinstance(turbulence_cfg, dict)
and turbulence_cfg:
4843 except ValueError
as e:
4844 errors.append(f
" {case_path}: {e}")
4846 les_cfg = turbulence_cfg.get(
'les')
4847 rans_cfg = turbulence_cfg.get(
'rans')
4848 wall_cfg = turbulence_cfg.get(
'wall_function')
4850 if isinstance(les_cfg, dict):
4851 for key
in (
'enabled',):
4852 if key
in les_cfg
and not isinstance(les_cfg[key], bool):
4853 errors.append(f
" {case_path}: models.physics.turbulence.les.{key} must be true or false.")
4854 for key
in (
'constant_cs',
'max_cs'):
4857 value = float(les_cfg[key])
4859 errors.append(f
" {case_path}: models.physics.turbulence.les.{key} must be nonnegative.")
4860 except (TypeError, ValueError):
4861 errors.append(f
" {case_path}: models.physics.turbulence.les.{key} must be numeric.")
4862 if 'dynamic_frequency' in les_cfg:
4864 value = int(les_cfg[
'dynamic_frequency'])
4866 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
4867 except (TypeError, ValueError):
4868 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
4870 if isinstance(rans_cfg, dict):
4871 if 'enabled' in rans_cfg
and not isinstance(rans_cfg[
'enabled'], bool):
4872 errors.append(f
" {case_path}: models.physics.turbulence.rans.enabled must be true or false.")
4874 rans_enabled = bool(rans_cfg.get(
'enabled',
True))
and normalize_rans_model(rans_cfg.get(
'model',
'k_omega')) != 0
4876 rans_enabled =
False
4879 f
"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
4883 f
"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
4886 if isinstance(wall_cfg, dict):
4887 if 'enabled' in wall_cfg
and not isinstance(wall_cfg[
'enabled'], bool):
4888 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
4889 if 'roughness_height' in wall_cfg:
4891 value = float(wall_cfg[
'roughness_height'])
4893 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
4894 except (TypeError, ValueError):
4895 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
4898 if not isinstance(solver_cfg, dict)
or not solver_cfg:
4899 errors.append(f
" {solver_path}: solver config is empty or not a valid YAML mapping.")
4901 strategy_cfg = solver_cfg.get(
'strategy', {})
4902 if not isinstance(strategy_cfg, dict):
4903 errors.append(f
" {solver_path}: 'strategy' must be a mapping.")
4904 elif 'implicit' in strategy_cfg:
4906 f
" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
4907 "Use 'strategy.momentum_solver' with named solver values."
4909 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
4912 except ValueError
as e:
4913 errors.append(f
" {solver_path}: {e}")
4915 op_mode_cfg = solver_cfg.get(
'operation_mode', {})
4916 if op_mode_cfg
is not None and not isinstance(op_mode_cfg, dict):
4917 errors.append(f
" {solver_path}: 'operation_mode' must be a mapping when provided.")
4918 elif isinstance(op_mode_cfg, dict):
4919 eulerian_source_mode =
None
4920 normalized_analytical_type =
None
4921 if 'eulerian_field_source' in op_mode_cfg:
4924 except ValueError
as e:
4925 errors.append(f
" {solver_path}: {e}")
4927 analytical_type = op_mode_cfg.get(
'analytical_type')
4928 if analytical_type
is not None:
4931 except ValueError
as e:
4932 errors.append(f
" {solver_path}: {e}")
4934 uniform_flow_cfg = op_mode_cfg.get(
'uniform_flow')
4935 if uniform_flow_cfg
is not None and not isinstance(uniform_flow_cfg, dict):
4936 errors.append(f
" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
4937 elif normalized_analytical_type ==
"UNIFORM_FLOW":
4938 if not isinstance(uniform_flow_cfg, dict):
4940 f
" {solver_path}: operation_mode.uniform_flow is required when "
4941 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
4944 for coord
in (
"u",
"v",
"w"):
4945 if coord
not in uniform_flow_cfg:
4947 f
" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
4951 float(uniform_flow_cfg[coord])
4952 except (TypeError, ValueError):
4954 f
" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
4956 elif uniform_flow_cfg
is not None:
4958 f
" {solver_path}: operation_mode.uniform_flow is only valid when "
4959 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
4962 if eulerian_source_mode ==
"analytical":
4963 effective_analytical_type = normalized_analytical_type
or "TGV3D"
4964 if effective_analytical_type ==
"TGV3D":
4965 if grid_mode !=
'programmatic_c':
4967 f
" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
4968 "'programmatic_c'. File-backed analytical ingestion is only supported for "
4969 "ZERO_FLOW and UNIFORM_FLOW."
4971 elif isinstance(grid_cfg.get(
'programmatic_settings'), dict):
4972 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
4975 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
4976 f
"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
4979 if grid_mode
not in {
'programmatic_c',
'file'}:
4981 f
" {case_path}: grid.mode '{grid_mode}' is not supported when "
4982 f
"operation_mode.analytical_type is '{effective_analytical_type}'. "
4983 "Use 'programmatic_c' or 'file'."
4985 elif grid_mode ==
'programmatic_c' and isinstance(grid_cfg.get(
'programmatic_settings'), dict):
4986 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
4989 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
4990 f
"operation_mode.analytical_type is '{effective_analytical_type}' and "
4991 "grid.mode is 'programmatic_c'."
4994 verification_cfg = solver_cfg.get(
'verification', {})
4995 if verification_cfg
is not None and not isinstance(verification_cfg, dict):
4996 errors.append(f
" {solver_path}: 'verification' must be a mapping when provided.")
4997 elif isinstance(verification_cfg, dict)
and verification_cfg:
4998 sources_cfg = verification_cfg.get(
'sources', {})
4999 if sources_cfg
is not None and not isinstance(sources_cfg, dict):
5000 errors.append(f
" {solver_path}: 'verification.sources' must be a mapping when provided.")
5001 elif isinstance(sources_cfg, dict)
and sources_cfg:
5002 diff_cfg = sources_cfg.get(
'diffusivity')
5003 scalar_cfg = sources_cfg.get(
'scalar')
5005 if diff_cfg
is not None:
5006 if not isinstance(diff_cfg, dict):
5007 errors.append(f
" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
5009 if eulerian_source_mode !=
"analytical":
5011 f
" {solver_path}: verification.sources.diffusivity is only valid when "
5012 "operation_mode.eulerian_field_source is 'analytical'."
5014 mode = diff_cfg.get(
'mode')
5015 profile = diff_cfg.get(
'profile')
5016 if str(mode).strip().lower() !=
"analytical":
5018 f
" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
5020 if str(profile).strip().upper() !=
"LINEAR_X":
5022 f
" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
5024 for key
in (
"gamma0",
"slope_x"):
5025 if key
not in diff_cfg:
5027 f
" {solver_path}: verification.sources.diffusivity.{key} is required."
5031 float(diff_cfg[key])
5032 except (TypeError, ValueError):
5034 f
" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
5037 if scalar_cfg
is not None:
5038 if not isinstance(scalar_cfg, dict):
5039 errors.append(f
" {solver_path}: 'verification.sources.scalar' must be a mapping.")
5041 if eulerian_source_mode !=
"analytical":
5043 f
" {solver_path}: verification.sources.scalar is only valid when "
5044 "operation_mode.eulerian_field_source is 'analytical'."
5046 mode = scalar_cfg.get(
'mode')
5047 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
5048 if str(mode).strip().lower() !=
"analytical":
5050 f
" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
5052 if profile
not in {
"CONSTANT",
"LINEAR_X",
"SIN_PRODUCT"}:
5054 f
" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
5056 required_scalar_keys = {
5057 "CONSTANT": (
"value",),
5058 "LINEAR_X": (
"phi0",
"slope_x"),
5059 "SIN_PRODUCT": (
"amplitude",
"kx",
"ky",
"kz"),
5061 for key
in required_scalar_keys:
5062 if key
not in scalar_cfg:
5064 f
" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
5068 float(scalar_cfg[key])
5069 except (TypeError, ValueError):
5071 f
" {solver_path}: verification.sources.scalar.{key} must be numeric."
5074 unknown_source_keys = sorted(set(sources_cfg.keys()) - {
"diffusivity",
"scalar"})
5075 if unknown_source_keys:
5077 f
" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
5078 "Currently supported: 'diffusivity', 'scalar'."
5080 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {
"sources"})
5081 if unknown_verification_keys:
5083 f
" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
5084 "Currently supported: 'sources'."
5087 transport_cfg = solver_cfg.get(
'scalar_transport', {})
5088 if transport_cfg
is not None and not isinstance(transport_cfg, dict):
5089 errors.append(f
" {solver_path}: 'scalar_transport' must be a mapping when provided.")
5090 elif isinstance(transport_cfg, dict):
5091 unknown_transport_keys = sorted(set(transport_cfg.keys()) - {
"schmidt_number",
"turbulent_schmidt_number"})
5092 if unknown_transport_keys:
5094 f
" {solver_path}: unsupported scalar_transport entries: {unknown_transport_keys}. "
5095 "Currently supported: 'schmidt_number', 'turbulent_schmidt_number'."
5097 for key
in (
"schmidt_number",
"turbulent_schmidt_number"):
5098 if key
in transport_cfg:
5100 value = float(transport_cfg[key])
5102 errors.append(f
" {solver_path}: scalar_transport.{key} must be positive.")
5103 except (TypeError, ValueError):
5104 errors.append(f
" {solver_path}: scalar_transport.{key} must be numeric.")
5106 tolerances_cfg = solver_cfg.get(
'tolerances', {})
5107 if tolerances_cfg
is not None and not isinstance(tolerances_cfg, dict):
5108 errors.append(f
" {solver_path}: 'tolerances' must be a mapping when provided.")
5109 elif isinstance(tolerances_cfg, dict):
5110 for key
in (
"absolute_tol",
"relative_tol",
"residual_absolute_tol",
"residual_relative_tol"):
5111 if key
in tolerances_cfg:
5113 float(tolerances_cfg[key])
5114 except (TypeError, ValueError):
5115 errors.append(f
" {solver_path}: tolerances.{key} must be numeric.")
5117 ms_cfg = solver_cfg.get(
'momentum_solver', {})
5118 if ms_cfg
is not None and not isinstance(ms_cfg, dict):
5119 errors.append(f
" {solver_path}: 'momentum_solver' must be a mapping when provided.")
5120 elif isinstance(ms_cfg, dict):
5121 unsupported_flat_keys = {
5122 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
5123 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
5124 'rk4_residual_noise_allowance_factor'
5126 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
5127 if present_unsupported:
5129 f
" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
5130 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
5133 allowed_ms_keys = {
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4',
'newton_krylov'}
5134 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
5137 f
" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
5138 "Currently supported: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
5140 if 'dual_time_picard_jameson_rk' in ms_cfg
and 'dual_time_picard_rk4' in ms_cfg:
5142 f
" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
5143 "do not also set its deprecated dual_time_picard_rk4 alias."
5146 selected_solver =
None
5147 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
5152 if selected_solver
is None:
5153 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
5155 has_dualtime_block = (
5156 'dual_time_picard_jameson_rk' in ms_cfg
or 'dual_time_picard_rk4' in ms_cfg
5158 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
5160 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
5161 f
"{selected_solver}. Solver-specific blocks must match the selected solver."
5164 newton_cfg = ms_cfg.get(
'newton_krylov')
5165 if newton_cfg
is not None:
5166 if selected_solver !=
"newton_krylov":
5168 f
" {solver_path}: momentum_solver.newton_krylov is set but selected solver is "
5169 f
"{selected_solver}. Solver-specific blocks must match the selected solver."
5173 except ValueError
as exc:
5174 errors.append(f
" {solver_path}: {exc}")
5176 dt_picard_cfg = ms_cfg.get(
'dual_time_picard_jameson_rk', ms_cfg.get(
'dual_time_picard_rk4'))
5177 if dt_picard_cfg
is not None:
5178 if not isinstance(dt_picard_cfg, dict):
5179 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
5182 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
5183 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
5184 'rk4_residual_noise_allowance_factor',
'ratio_ema_alpha'
5186 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
5189 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
5191 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
5192 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
5194 f
" {solver_path}: use only jameson_residual_noise_allowance_factor; "
5195 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
5197 if 'pseudo_cfl' in dt_picard_cfg:
5198 pcfl_cfg = dt_picard_cfg[
'pseudo_cfl']
5199 if not isinstance(pcfl_cfg, dict):
5200 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
5202 allowed_pcfl_keys = {
'initial',
'minimum',
'maximum',
'growth_factor',
'reduction_factor'}
5203 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
5204 if unknown_pcfl_keys:
5206 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
5209 for key
in allowed_pcfl_keys:
5212 numeric_pcfl[key] = float(pcfl_cfg[key])
5213 except (TypeError, ValueError):
5215 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
5217 if numeric_pcfl.get(
'minimum', 1.0) <= 0.0:
5218 errors.append(f
" {solver_path}: pseudo_cfl.minimum must be positive.")
5219 if numeric_pcfl.get(
'growth_factor', 1.0) < 1.0:
5220 errors.append(f
" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
5221 reduction = numeric_pcfl.get(
'reduction_factor', 1.0)
5222 if reduction <= 0.0
or reduction >= 1.0:
5223 errors.append(f
" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
5224 if all(key
in numeric_pcfl
for key
in (
'minimum',
'initial',
'maximum')):
5225 if not numeric_pcfl[
'minimum'] <= numeric_pcfl[
'initial'] <= numeric_pcfl[
'maximum']:
5226 errors.append(f
" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
5228 'jameson_residual_noise_allowance_factor'
5229 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
5230 else 'rk4_residual_noise_allowance_factor'
5232 if noise_key
in dt_picard_cfg:
5234 if float(dt_picard_cfg[noise_key]) < 1.0:
5235 errors.append(f
" {solver_path}: {noise_key} must be at least 1.")
5236 except (TypeError, ValueError):
5237 errors.append(f
" {solver_path}: {noise_key} must be numeric.")
5238 if 'ratio_ema_alpha' in dt_picard_cfg:
5240 alpha_val = float(dt_picard_cfg[
'ratio_ema_alpha'])
5241 if not 0.0 <= alpha_val <= 1.0:
5242 errors.append(f
" {solver_path}: ratio_ema_alpha must be in [0, 1].")
5243 except (TypeError, ValueError):
5244 errors.append(f
" {solver_path}: ratio_ema_alpha must be numeric.")
5246 solution_convergence_cfg = solver_cfg.get(
'solution_convergence', {})
5247 if solution_convergence_cfg
is not None and not isinstance(solution_convergence_cfg, dict):
5248 errors.append(f
" {solver_path}: 'solution_convergence' must be a mapping when provided.")
5249 elif isinstance(solution_convergence_cfg, dict)
and solution_convergence_cfg:
5250 allowed_solution_convergence_keys = {
5251 'enabled',
'mode',
'periodic_deterministic',
'statistical_steady'
5253 unknown_solution_convergence_keys = sorted(set(solution_convergence_cfg.keys()) - allowed_solution_convergence_keys)
5254 if unknown_solution_convergence_keys:
5256 f
" {solver_path}: unsupported solution_convergence keys: {unknown_solution_convergence_keys}."
5259 mode = solution_convergence_cfg.get(
'mode',
'steady_deterministic')
5262 except ValueError
as e:
5263 errors.append(f
" {solver_path}: {e}")
5264 normalized_solution_mode =
None
5266 periodic_cfg = solution_convergence_cfg.get(
'periodic_deterministic')
5267 statistical_cfg = solution_convergence_cfg.get(
'statistical_steady')
5268 if periodic_cfg
is not None and not isinstance(periodic_cfg, dict):
5269 errors.append(f
" {solver_path}: solution_convergence.periodic_deterministic must be a mapping when provided.")
5270 if statistical_cfg
is not None and not isinstance(statistical_cfg, dict):
5271 errors.append(f
" {solver_path}: solution_convergence.statistical_steady must be a mapping when provided.")
5273 if isinstance(periodic_cfg, dict):
5274 unknown_periodic_keys = sorted(set(periodic_cfg.keys()) - {
'period_steps'})
5275 if unknown_periodic_keys:
5277 f
" {solver_path}: unsupported keys in solution_convergence.periodic_deterministic: {unknown_periodic_keys}."
5279 period_steps = periodic_cfg.get(
'period_steps')
5280 if period_steps
is not None and (
not isinstance(period_steps, int)
or period_steps <= 0):
5281 errors.append(f
" {solver_path}: solution_convergence.periodic_deterministic.period_steps must be a positive integer.")
5283 if isinstance(statistical_cfg, dict):
5284 unknown_statistical_keys = sorted(set(statistical_cfg.keys()) - {
'window_steps'})
5285 if unknown_statistical_keys:
5287 f
" {solver_path}: unsupported keys in solution_convergence.statistical_steady: {unknown_statistical_keys}."
5289 window_steps = statistical_cfg.get(
'window_steps')
5290 if window_steps
is not None and (
not isinstance(window_steps, int)
or window_steps <= 0):
5291 errors.append(f
" {solver_path}: solution_convergence.statistical_steady.window_steps must be a positive integer.")
5293 if normalized_solution_mode ==
"PERIODIC_DETERMINISTIC":
5294 if not isinstance(periodic_cfg, dict)
or 'period_steps' not in periodic_cfg:
5296 f
" {solver_path}: solution_convergence.periodic_deterministic.period_steps is required when mode is 'periodic_deterministic'."
5298 elif periodic_cfg
is not None:
5300 f
" {solver_path}: solution_convergence.periodic_deterministic is only valid when mode is 'periodic_deterministic'."
5303 if normalized_solution_mode ==
"STATISTICAL_STEADY":
5304 if not isinstance(statistical_cfg, dict)
or 'window_steps' not in statistical_cfg:
5306 f
" {solver_path}: solution_convergence.statistical_steady.window_steps is required when mode is 'statistical_steady'."
5308 elif statistical_cfg
is not None:
5310 f
" {solver_path}: solution_convergence.statistical_steady is only valid when mode is 'statistical_steady'."
5314 interp_cfg = solver_cfg.get(
'interpolation', {})
if isinstance(solver_cfg, dict)
else {}
5315 if interp_cfg
is not None and not isinstance(interp_cfg, dict):
5316 errors.append(f
" {solver_path}: 'interpolation' must be a mapping when provided.")
5317 elif isinstance(interp_cfg, dict)
and 'method' in interp_cfg:
5320 except ValueError
as e:
5321 errors.append(f
" {solver_path}: {e}")
5324 if not isinstance(monitor_cfg, dict)
or not monitor_cfg:
5325 errors.append(f
" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
5327 io_cfg = monitor_cfg.get(
'io', {})
5328 freq = io_cfg.get(
'data_output_frequency')
5329 if freq
is not None and (
not isinstance(freq, int)
or freq <= 0):
5330 errors.append(f
" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
5331 particle_console_freq = io_cfg.get(
'particle_console_output_frequency')
5332 if particle_console_freq
is not None and (
not isinstance(particle_console_freq, int)
or particle_console_freq < 0):
5334 f
" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
5335 f
"(got {particle_console_freq})."
5339 except ValueError
as e:
5340 errors.append(f
" {monitor_path}: {e}")
5343 except ValueError
as e:
5344 errors.append(f
" {monitor_path}: {e}")
5347 except ValueError
as e:
5348 errors.append(f
" {monitor_path}: {e}")
5353 f
"{case_path}: This configuration requires restart data (start_step > 0, "
5354 "eulerian_field_source='load', or particle restart_mode='load'). "
5355 "Use --restart-from or --continue when running."
5360 for warning
in warnings:
5361 print(f
"[WARN] {warning}", file=sys.stderr)
5366 @brief Validates the post-processing config before running the post-processor.
5367 @param[in] post_cfg Parsed post-processing YAML dictionary.
5368 @param[in] post_path Path to post file (for error messages).
5369 @throws SystemExit on validation failure.
5375 if not isinstance(post_cfg, dict)
or not post_cfg:
5376 errors.append(f
" {post_path}: post-processing config is empty or not a valid YAML mapping.")
5380 if 'run_control' not in post_cfg:
5381 errors.append(f
" {post_path}: missing required section 'run_control'.")
5383 rc = post_cfg.get(
'run_control', {})
5384 if not isinstance(rc, dict):
5385 errors.append(f
" {post_path}: 'run_control' must be a mapping.")
5387 for canonical_key, aliases
in POST_RUN_CONTROL_ALIASES.items():
5388 if not any(alias
in rc
for alias
in aliases):
5389 alias_list =
"', '".join(aliases)
5391 f
" {post_path}: missing required key 'run_control.{canonical_key}' "
5392 f
"(accepted aliases: '{alias_list}')."
5398 except (TypeError, ValueError):
5399 alias_name = next((alias
for alias
in aliases
if alias
in rc), canonical_key)
5401 f
" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
5405 io_cfg = post_cfg.get(
'io', {})
5406 source_cfg = post_cfg.get(
'source_data')
5407 if source_cfg
is not None and not isinstance(source_cfg, dict):
5408 errors.append(f
" {post_path}: 'source_data' must be a mapping when provided.")
5409 global_ops = post_cfg.get(
'global_operations')
5410 if global_ops
is not None:
5411 if not isinstance(global_ops, dict):
5412 errors.append(f
" {post_path}: 'global_operations' must be a mapping when provided.")
5413 elif 'dimensionalize' in global_ops
and not isinstance(global_ops.get(
'dimensionalize'), bool):
5414 errors.append(f
" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
5416 errors.append(f
" {post_path}: missing required section 'io'.")
5417 elif not isinstance(io_cfg, dict):
5418 errors.append(f
" {post_path}: 'io' must be a mapping.")
5420 for k
in [
'output_directory',
'output_filename_prefix']:
5422 errors.append(f
" {post_path}: missing required key 'io.{k}'.")
5423 for key_name
in (
'output_directory',
'output_filename_prefix',
'particle_filename_prefix'):
5424 if key_name
in io_cfg
and not isinstance(io_cfg.get(key_name), str):
5425 errors.append(f
" {post_path}: 'io.{key_name}' must be a string when provided.")
5426 if 'output_particles' in io_cfg
and not isinstance(io_cfg.get(
'output_particles'), bool):
5427 errors.append(f
" {post_path}: 'io.output_particles' must be a boolean when provided.")
5428 particle_subsampling_frequency = io_cfg.get(
'particle_subsampling_frequency')
5429 if particle_subsampling_frequency
is not None:
5430 if not isinstance(particle_subsampling_frequency, int)
or particle_subsampling_frequency <= 0:
5432 f
" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
5434 input_extensions = io_cfg.get(
'input_extensions')
5436 if input_extensions
is not None:
5437 if not isinstance(input_extensions, dict):
5438 errors.append(f
" {post_path}: 'io.input_extensions' must be a mapping when provided.")
5440 for ext_key
in (
'eulerian',
'particle'):
5441 ext_val = input_extensions.get(ext_key)
5442 if ext_val
is not None and not isinstance(ext_val, str):
5443 errors.append(f
" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
5444 if source_input_extensions
is not None:
5445 if not isinstance(source_input_extensions, dict):
5446 errors.append(f
" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
5448 for ext_key
in (
'eulerian',
'particle'):
5449 ext_val = source_input_extensions.get(ext_key)
5450 if ext_val
is not None and not isinstance(ext_val, str):
5452 f
" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
5455 averaged_fields = io_cfg.get(
'eulerian_fields_averaged')
5456 if averaged_fields
is not None and not isinstance(averaged_fields, list):
5457 errors.append(f
" {post_path}: 'io.eulerian_fields_averaged' must be a list when provided.")
5458 for list_key
in (
'eulerian_fields',
'particle_fields'):
5459 list_val = io_cfg.get(list_key)
5460 if list_val
is not None and not isinstance(list_val, list):
5461 errors.append(f
" {post_path}: 'io.{list_key}' must be a list when provided.")
5464 eulerian_pipeline = post_cfg.get(
'eulerian_pipeline', [])
5465 if eulerian_pipeline
is not None and not isinstance(eulerian_pipeline, list):
5466 errors.append(f
" {post_path}: 'eulerian_pipeline' must be a list when provided.")
5467 eulerian_pipeline = []
5468 for i, entry
in enumerate(eulerian_pipeline):
5469 if not isinstance(entry, dict)
or 'task' not in entry:
5470 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
5471 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
5473 task_name = entry.get(
'task')
5474 if task_name ==
'q_criterion':
5476 if task_name ==
'nodal_average':
5477 in_field = entry.get(
'input_field')
5478 out_field = entry.get(
'output_field')
5479 if not isinstance(in_field, str)
or not in_field.strip():
5480 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
5481 if not isinstance(out_field, str)
or not out_field.strip():
5482 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
5483 if isinstance(in_field, str)
and isinstance(out_field, str)
and in_field == out_field:
5485 f
" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
5488 if task_name ==
'normalize_field':
5489 field = entry.get(
'field',
'P')
5490 if not isinstance(field, str)
or not field.strip():
5491 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
5494 f
" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
5497 reference_point = entry.get(
'reference_point', [1, 1, 1])
5498 if not isinstance(reference_point, (list, tuple))
or len(reference_point) != 3:
5500 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
5503 for rp_idx, coord
in enumerate(reference_point):
5506 except (TypeError, ValueError):
5508 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
5509 "must be integer-compatible."
5513 f
" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]."
5517 lagrangian_pipeline = post_cfg.get(
'lagrangian_pipeline', [])
5518 if lagrangian_pipeline
is not None and not isinstance(lagrangian_pipeline, list):
5519 errors.append(f
" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
5520 lagrangian_pipeline = []
5521 for i, entry
in enumerate(lagrangian_pipeline):
5522 if not isinstance(entry, dict)
or 'task' not in entry:
5523 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
5525 task_name = entry.get(
'task')
5526 if task_name ==
'specific_ke':
5527 in_field = entry.get(
'input_field')
5528 out_field = entry.get(
'output_field')
5529 if not isinstance(in_field, str)
or not in_field.strip():
5530 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
5531 if not isinstance(out_field, str)
or not out_field.strip():
5532 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
5535 f
" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
5539 stats_cfg = post_cfg.get(
'statistics_pipeline')
5541 if stats_cfg
is not None:
5542 if isinstance(stats_cfg, list):
5543 stats_entries = stats_cfg
5544 elif isinstance(stats_cfg, dict):
5545 stats_entries = stats_cfg.get(
'tasks', [])
5546 if not isinstance(stats_entries, list):
5547 errors.append(f
" {post_path}: 'statistics_pipeline.tasks' must be a list.")
5548 stats_output_prefix = stats_cfg.get(
'output_prefix')
5549 if stats_output_prefix
is not None and not isinstance(stats_output_prefix, str):
5550 errors.append(f
" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
5553 f
" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
5555 for i, entry
in enumerate(stats_entries):
5556 if isinstance(entry, str):
5558 elif isinstance(entry, dict)
and 'task' in entry:
5559 task_name = entry.get(
'task')
5562 f
" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
5567 except ValueError
as e:
5568 errors.append(f
" {post_path}: {e}")
5570 legacy_stats_output_prefix = post_cfg.get(
'statistics_output_prefix')
5571 if legacy_stats_output_prefix
is not None and not isinstance(legacy_stats_output_prefix, str):
5572 errors.append(f
" {post_path}: 'statistics_output_prefix' must be a string when provided.")
5579 @brief Validate Slurm scheduler configuration from cluster.yml.
5580 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
5581 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
5586 if not isinstance(cluster_cfg, dict)
or not cluster_cfg:
5587 errors.append(f
" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
5590 scheduler = cluster_cfg.get(
"scheduler", {})
5591 if not isinstance(scheduler, dict):
5592 errors.append(f
" {cluster_path}: 'scheduler' must be a mapping.")
5594 scheduler_type = scheduler.get(
"type",
"slurm")
5595 if str(scheduler_type).lower() !=
"slurm":
5596 errors.append(f
" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
5598 resources = cluster_cfg.get(
"resources", {})
5599 if not isinstance(resources, dict):
5600 errors.append(f
" {cluster_path}: 'resources' must be a mapping.")
5602 for req
in (
"account",
"nodes",
"ntasks_per_node",
"mem",
"time"):
5603 if req
not in resources:
5604 errors.append(f
" {cluster_path}: missing required key 'resources.{req}'.")
5605 for int_key
in (
"nodes",
"ntasks_per_node"):
5606 if int_key
in resources:
5607 val = resources.get(int_key)
5608 if not isinstance(val, int)
or val <= 0:
5609 errors.append(f
" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
5610 for str_key
in (
"account",
"mem",
"time",
"partition"):
5611 if str_key
in resources
and resources.get(str_key)
is not None:
5612 if not isinstance(resources.get(str_key), str):
5613 errors.append(f
" {cluster_path}: resources.{str_key} must be a string when provided.")
5614 if isinstance(resources.get(
"time"), str):
5617 except ValueError
as exc:
5619 f
" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
5621 account = resources.get(
"account")
5622 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
5624 f
"{cluster_path}: resources.account still uses the sample placeholder "
5625 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
5628 notifications = cluster_cfg.get(
"notifications", {})
5629 if notifications
is not None and not isinstance(notifications, dict):
5630 errors.append(f
" {cluster_path}: 'notifications' must be a mapping when provided.")
5631 elif isinstance(notifications, dict):
5632 mail_user = notifications.get(
"mail_user")
5634 errors.append(f
" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
5635 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
5637 f
"{cluster_path}: notifications.mail_user still uses the sample placeholder "
5638 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
5640 mail_type = notifications.get(
"mail_type")
5641 if mail_type
is not None and not isinstance(mail_type, str):
5642 errors.append(f
" {cluster_path}: notifications.mail_type must be a string when provided.")
5644 execution = cluster_cfg.get(
"execution", {})
5645 if execution
is not None and not isinstance(execution, dict):
5646 errors.append(f
" {cluster_path}: 'execution' must be a mapping when provided.")
5647 elif isinstance(execution, dict):
5648 module_setup = execution.get(
"module_setup", [])
5649 if module_setup
is not None and not isinstance(module_setup, list):
5650 errors.append(f
" {cluster_path}: execution.module_setup must be a list of shell lines.")
5651 elif isinstance(module_setup, list):
5652 for i, line
in enumerate(module_setup):
5653 if not isinstance(line, str):
5654 errors.append(f
" {cluster_path}: execution.module_setup[{i}] must be a string.")
5656 launcher = execution.get(
"launcher")
5657 if launcher
is not None and not isinstance(launcher, str):
5658 errors.append(f
" {cluster_path}: execution.launcher must be a string when provided.")
5659 launcher_args = execution.get(
"launcher_args")
5660 if launcher_args
is not None and not isinstance(launcher_args, list):
5661 errors.append(f
" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
5662 elif isinstance(launcher_args, list):
5663 for i, token
in enumerate(launcher_args):
5664 if not isinstance(token, (str, int, float)):
5665 errors.append(f
" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
5668 f
" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
5669 "split whitespace-separated arguments into separate list items."
5671 if (launcher
is None or isinstance(launcher, str))
and (launcher_args
is None or isinstance(launcher_args, list)):
5674 except ValueError
as exc:
5675 errors.append(f
" {cluster_path}: {exc}.")
5677 extra_sbatch = execution.get(
"extra_sbatch")
5678 if extra_sbatch
is not None and not isinstance(extra_sbatch, (dict, list)):
5679 errors.append(f
" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
5681 walltime_guard = execution.get(
"walltime_guard")
5682 if walltime_guard
is not None and not isinstance(walltime_guard, dict):
5683 errors.append(f
" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
5684 elif isinstance(walltime_guard, dict):
5685 enabled = walltime_guard.get(
"enabled")
5686 if enabled
is not None and not isinstance(enabled, bool):
5687 errors.append(f
" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
5689 warmup_steps = walltime_guard.get(
"warmup_steps")
5690 if warmup_steps
is not None and (
not isinstance(warmup_steps, int)
or isinstance(warmup_steps, bool)
or warmup_steps <= 0):
5692 f
" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
5695 multiplier = walltime_guard.get(
"multiplier")
5696 if multiplier
is not None:
5697 if isinstance(multiplier, bool)
or not isinstance(multiplier, (int, float))
or multiplier <= 0.0:
5699 f
" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
5701 elif float(multiplier) > 5.0:
5703 f
" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
5706 min_seconds = walltime_guard.get(
"min_seconds")
5707 if min_seconds
is not None and (
5708 isinstance(min_seconds, bool)
or not isinstance(min_seconds, (int, float))
or float(min_seconds) <= 0.0
5711 f
" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
5714 estimator_alpha = walltime_guard.get(
"estimator_alpha")
5715 if estimator_alpha
is not None:
5716 if isinstance(estimator_alpha, bool)
or not isinstance(estimator_alpha, (int, float)):
5718 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
5720 elif float(estimator_alpha) <= 0.0
or float(estimator_alpha) > 1.0:
5722 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
5726 for warning
in warnings:
5727 print(f
"[WARN] {warning}", file=sys.stderr)
5734 @brief Validate sweep/study specification from study.yml.
5735 @param[in] study_cfg Argument passed to `validate_study_config()`.
5736 @param[in] study_path Argument passed to `validate_study_config()`.
5737 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
5741 if not isinstance(study_cfg, dict)
or not study_cfg:
5742 errors.append(f
" {study_path}: study config is empty or not a valid YAML mapping.")
5745 base_cfgs = study_cfg.get(
"base_configs")
5746 if not isinstance(base_cfgs, dict):
5747 errors.append(f
" {study_path}: missing required mapping 'base_configs'.")
5749 for req
in (
"case",
"solver",
"monitor",
"post"):
5750 path_val = base_cfgs.get(req)
5751 if not path_val
or not isinstance(path_val, str):
5752 errors.append(f
" {study_path}: base_configs.{req} must be a path string.")
5753 elif not skip_base_file_check:
5755 if not os.path.isfile(resolved):
5756 errors.append(f
" {study_path}: base_configs.{req} does not exist: {resolved}")
5758 study_type = study_cfg.get(
"study_type")
5759 allowed_types = {
"grid_independence",
"timestep_independence",
"sensitivity"}
5760 if study_type
not in allowed_types:
5762 f
" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
5765 parameters = study_cfg.get(
"parameters")
5766 parameter_sets = study_cfg.get(
"parameter_sets")
5767 allowed_roots = {
"case",
"solver",
"monitor",
"post"}
5768 if bool(parameters) == bool(parameter_sets):
5769 errors.append(f
" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
5770 elif parameter_sets:
5771 if not isinstance(parameter_sets, list)
or not parameter_sets:
5772 errors.append(f
" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
5774 for set_index, param_set
in enumerate(parameter_sets):
5775 if not isinstance(param_set, dict)
or not param_set:
5777 f
" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
5780 for key, value
in param_set.items():
5781 if not isinstance(key, str)
or "." not in key:
5783 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
5786 root = key.split(
".", 1)[0]
5787 if root
not in allowed_roots:
5789 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
5791 if isinstance(value, (dict, list)):
5793 f
" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
5796 if not isinstance(parameters, dict)
or not parameters:
5797 errors.append(f
" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
5799 for key, values
in parameters.items():
5800 if not isinstance(key, str)
or "." not in key:
5802 f
" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
5805 root = key.split(
".", 1)[0]
5806 if root
not in allowed_roots:
5808 f
" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
5810 if not isinstance(values, list)
or len(values) == 0:
5811 errors.append(f
" {study_path}: parameters.{key} must be a non-empty list.")
5813 metrics = study_cfg.get(
"metrics", [])
5814 if metrics
is not None and not isinstance(metrics, list):
5815 errors.append(f
" {study_path}: 'metrics' must be a list when provided.")
5816 elif isinstance(metrics, list):
5817 for i, metric
in enumerate(metrics):
5818 if isinstance(metric, str):
5820 if not isinstance(metric, dict):
5822 f
" {study_path}: metrics[{i}] must be a string or mapping."
5825 if "name" not in metric:
5826 errors.append(f
" {study_path}: metrics[{i}] missing required key 'name'.")
5827 if "source" not in metric:
5828 errors.append(f
" {study_path}: metrics[{i}] missing required key 'source'.")
5830 plotting = study_cfg.get(
"plotting", {})
5831 if plotting
is not None and not isinstance(plotting, dict):
5832 errors.append(f
" {study_path}: 'plotting' must be a mapping when provided.")
5833 elif isinstance(plotting, dict):
5834 enabled = plotting.get(
"enabled")
5835 if enabled
is not None and not isinstance(enabled, bool):
5836 errors.append(f
" {study_path}: plotting.enabled must be boolean when provided.")
5837 output_format = plotting.get(
"output_format")
5838 if output_format
is not None and output_format
not in {
"png",
"pdf",
"svg"}:
5839 errors.append(f
" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
5841 execution = study_cfg.get(
"execution", {})
5842 if execution
is not None and not isinstance(execution, dict):
5843 errors.append(f
" {study_path}: 'execution' must be a mapping when provided.")
5844 elif isinstance(execution, dict):
5845 max_conc = execution.get(
"max_concurrent_array_tasks")
5846 if max_conc
is not None and (
not isinstance(max_conc, int)
or max_conc <= 0):
5848 f
" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
5856 @brief Set nested dictionary value, creating intermediate maps when needed.
5857 @param[in] container Argument passed to `_deep_set()`.
5858 @param[in] dotted_path Argument passed to `_deep_set()`.
5859 @param[in] value Argument passed to `_deep_set()`.
5861 keys = dotted_path.split(
".")
5863 for key
in keys[:-1]:
5864 if key
not in current
or not isinstance(current[key], dict):
5866 current = current[key]
5867 current[keys[-1]] = value
5871 @brief Expand study parameter lists into cartesian-product combinations.
5872 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
5873 @return Value returned by `expand_parameter_matrix()`.
5875 param_keys =
list(parameters.keys())
5876 all_values = [parameters[k]
for k
in param_keys]
5878 for combo
in itertools.product(*all_values):
5879 combos.append(dict(zip(param_keys, combo)))
5885 @brief Expand either cartesian-study parameters or explicit parameter sets.
5886 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
5887 @return Value returned by `expand_study_parameter_combinations()`.
5889 parameter_sets = study_cfg.get(
"parameter_sets")
5891 return [dict(param_set)
for param_set
in parameter_sets]
5897 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
5898 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
5899 @return Value returned by `get_study_parameter_keys()`.
5901 parameters = study_cfg.get(
"parameters")
5902 if isinstance(parameters, dict)
and parameters:
5903 return list(parameters.keys())
5906 parameter_sets = study_cfg.get(
"parameter_sets")
or []
5907 for param_set
in parameter_sets:
5908 if not isinstance(param_set, dict):
5910 for key
in param_set.keys():
5918 @brief Return cluster total tasks.
5919 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
5920 @return Value returned by `get_cluster_total_tasks()`.
5922 resources = cluster_cfg.get(
"resources", {})
5923 return int(resources.get(
"nodes", 1)) * int(resources.get(
"ntasks_per_node", 1))
5927 @brief Normalize extension.
5928 @param[in] ext Argument passed to `normalize_extension()`.
5929 @return Value returned by `normalize_extension()`.
5933 return str(ext).strip().lstrip(
".")
5942 stderr_path: str =
None,
5943 env_vars: dict =
None,
5944 shell_env_vars: dict =
None,
5945 array_spec: str =
None
5948 @brief Render a Slurm batch script for a single command.
5949 @param[in] script_path Argument passed to `render_slurm_script()`.
5950 @param[in] job_name Argument passed to `render_slurm_script()`.
5951 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
5952 @param[in] command Argument passed to `render_slurm_script()`.
5953 @param[in] workdir Argument passed to `render_slurm_script()`.
5954 @param[in] stdout_path Argument passed to `render_slurm_script()`.
5955 @param[in] stderr_path Argument passed to `render_slurm_script()`.
5956 @param[in] env_vars Argument passed to `render_slurm_script()`.
5957 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
5958 @param[in] array_spec Argument passed to `render_slurm_script()`.
5960 resources = cluster_cfg.get(
"resources", {})
5961 notifications = cluster_cfg.get(
"notifications", {})
or {}
5962 execution = cluster_cfg.get(
"execution", {})
or {}
5963 extra_sbatch = execution.get(
"extra_sbatch")
5964 module_setup = execution.get(
"module_setup", [])
or []
5966 if stderr_path
is None:
5967 stderr_path = stdout_path.replace(
".out",
".err")
5971 f
"#SBATCH --job-name={job_name}",
5972 f
"#SBATCH --nodes={resources['nodes']}",
5973 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
5974 f
"#SBATCH --mem={resources['mem']}",
5975 f
"#SBATCH --time={resources['time']}",
5976 f
"#SBATCH --output={stdout_path}",
5977 f
"#SBATCH --error={stderr_path}",
5978 f
"#SBATCH --account={resources['account']}",
5980 partition = resources.get(
"partition")
5982 lines.append(f
"#SBATCH --partition={partition}")
5984 lines.append(f
"#SBATCH --array={array_spec}")
5985 mail_user = notifications.get(
"mail_user")
5986 mail_type = notifications.get(
"mail_type")
5988 lines.append(f
"#SBATCH --mail-user={mail_user}")
5990 lines.append(f
"#SBATCH --mail-type={mail_type}")
5992 if isinstance(extra_sbatch, dict):
5993 for key, value
in extra_sbatch.items():
5995 if not flag.startswith(
"--"):
5997 if isinstance(value, bool):
5999 lines.append(f
"#SBATCH {flag}")
6000 elif value
is not None:
6001 lines.append(f
"#SBATCH {flag}={value}")
6002 elif isinstance(extra_sbatch, list):
6003 for token
in extra_sbatch:
6004 lines.append(f
"#SBATCH {token}")
6009 "set -euo pipefail",
6011 f
"cd {shlex.quote(workdir)}",
6012 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
6013 'echo "[$(date)] Working directory: $PWD"',
6018 for key, value
in shell_env_vars.items():
6019 lines.append(f
"export {key}={value}")
6021 for setup_line
in module_setup:
6022 lines.append(str(setup_line))
6025 for key, value
in env_vars.items():
6026 lines.append(f
"export {key}={shlex.quote(str(value))}")
6028 cmd =
" ".join(shlex.quote(str(tok))
for tok
in command)
6029 lines.append(f
"exec {cmd}")
6031 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
6032 with open(script_path,
"w")
as f:
6033 f.write(
"\n".join(lines) +
"\n")
6034 os.chmod(script_path, 0o755)
6037 launcher:
"str | None",
6038 launcher_args:
"list | None" =
None,
6039 label: str =
"launcher",
6040) ->
"tuple[str | None, list[str]]":
6042 @brief Canonicalize launcher config into executable token plus argv-style flags.
6043 @param[in] launcher Argument passed to `split_launcher_tokens()`.
6044 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
6045 @param[in] label Argument passed to `split_launcher_tokens()`.
6046 @return Value returned by `split_launcher_tokens()`.
6048 normalized_args = [str(x)
for x
in (launcher_args
or [])]
6050 if launcher
is None:
6051 return None, normalized_args
6054 launcher_tokens = shlex.split(str(launcher))
6055 except ValueError
as exc:
6056 raise ValueError(f
"{label} is not shell-parseable: {exc}")
from exc
6058 if not launcher_tokens:
6059 return None, normalized_args
6061 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
6066 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
6067 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
6068 @return Value returned by `normalize_cluster_launcher()`.
6071 execution.get(
"launcher"),
6072 execution.get(
"launcher_args")
or [],
6073 label=
"execution.launcher",
6079 @brief Remove explicit MPI task-count flags from known launchers.
6080 @param[in] launcher_name Basename-normalized launcher executable.
6081 @param[in] launcher_args Launcher argument list.
6082 @return Filtered launcher arguments with explicit size flags removed.
6086 while idx < len(launcher_args):
6087 token = str(launcher_args[idx])
6089 if launcher_name ==
"srun":
6090 if token
in {
"-n",
"--ntasks"}:
6093 if token.startswith(
"--ntasks="):
6096 elif launcher_name
in {
"mpiexec",
"mpirun"}:
6097 if token
in {
"-n",
"-np"}:
6100 if token.startswith(
"-n=")
or token.startswith(
"-np="):
6104 filtered.append(token)
6112 @brief Clone cluster config and force a single-node post stage task layout.
6113 @param[in] cluster_cfg Base cluster configuration.
6114 @param[in] num_procs Number of post tasks to request.
6115 @return Cluster configuration specialized for the post stage.
6117 post_cluster_cfg = copy.deepcopy(cluster_cfg)
6118 post_cluster_cfg.setdefault(
"resources", {})
6119 post_cluster_cfg[
"resources"][
"nodes"] = 1
6120 post_cluster_cfg[
"resources"][
"ntasks_per_node"] = int(num_procs)
6121 return post_cluster_cfg
6126 executable_args: list,
6128 config_search_anchor: str =
None,
6129 allow_single_rank_launcher_override: bool =
False,
6130 force_num_procs:
"int | None" =
None,
6133 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
6134 @param[in] executable Argument passed to `build_local_launch_command()`.
6135 @param[in] executable_args Argument passed to `build_local_launch_command()`.
6136 @param[in] num_procs Argument passed to `build_local_launch_command()`.
6137 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
6138 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
6139 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6140 @return Value returned by `build_local_launch_command()`.
6142 target_num_procs = force_num_procs
if force_num_procs
is not None else num_procs
6143 command = [executable] + executable_args
6144 if target_num_procs <= 1
and not allow_single_rank_launcher_override:
6147 launcher_override = os.environ.get(
"PICURV_MPI_LAUNCHER")
6148 if launcher_override
is None:
6149 launcher_override = os.environ.get(
"MPI_LAUNCHER")
6152 if launcher_override
is not None:
6153 explicit_launcher_config =
True
6156 label=
"local MPI launcher override",
6161 configured_launcher = local_execution.get(
"launcher")
6162 configured_args = local_execution.get(
"launcher_args")
or []
6163 explicit_launcher_config = configured_launcher
is not None or bool(configured_args)
6164 if target_num_procs <= 1
and not explicit_launcher_config:
6167 configured_launcher
if configured_launcher
is not None else "mpiexec",
6169 label=
"local_execution.launcher",
6171 except ValueError
as exc:
6172 print(f
"[FATAL] {exc}", file=sys.stderr)
6178 launcher_name = os.path.basename(launcher).lower()
6179 if force_num_procs
is not None:
6181 prefix = [launcher] + launcher_args
6183 if launcher_name ==
"srun":
6184 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
6186 prefix += [
"-n", str(target_num_procs)]
6187 elif launcher_name
in {
"mpiexec",
"mpirun"}:
6188 has_n = any(token
in {
"-n",
"-np"}
for token
in launcher_args)
6190 prefix += [
"-n", str(target_num_procs)]
6192 return prefix + command
6196 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
6197 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
6198 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
6199 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
6200 @return Value returned by `resolve_cluster_execution()`.
6204 execution = cluster_cfg.get(
"execution", {})
or {}
6205 cluster_override = {
6206 "launcher": execution.get(
"launcher")
if "launcher" in execution
else None,
6207 "launcher_args": execution.get(
"launcher_args")
if "launcher_args" in execution
else None,
6215 executable_args: list,
6216 config_search_anchor: str =
None,
6217 extra_search_anchors=
None,
6218 force_num_procs:
"int | None" =
None,
6221 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
6222 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
6223 @param[in] executable Argument passed to `build_cluster_launch_command()`.
6224 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
6225 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
6226 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
6227 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6228 @return Value returned by `build_cluster_launch_command()`.
6233 config_search_anchor=config_search_anchor,
6234 extra_search_anchors=extra_search_anchors,
6237 execution.get(
"launcher")
if execution.get(
"launcher")
is not None else "srun",
6238 execution.get(
"launcher_args")
or [],
6239 label=
"cluster execution launcher",
6241 except ValueError
as exc:
6242 print(f
"[FATAL] {exc}", file=sys.stderr)
6246 launcher_name = launcher.lower()
if launcher
else ""
6247 if force_num_procs
is not None:
6250 if launcher
and launcher_name ==
"srun":
6251 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
6252 cmd = [
"srun"] + launcher_args
6254 cmd += [
"-n", str(ntasks)]
6255 return cmd + [executable] + executable_args
6257 if launcher
and launcher_name ==
"mpirun":
6258 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
6259 cmd = [
"mpirun"] + launcher_args
6261 cmd += [
"-np", str(ntasks)]
6262 return cmd + [executable] + executable_args
6264 if launcher
and launcher_name ==
"mpiexec":
6265 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
6266 cmd = [
"mpiexec"] + launcher_args
6268 cmd += [
"-np", str(ntasks)]
6269 return cmd + [executable] + executable_args
6274 cmd.append(str(launcher))
6275 cmd += launcher_args
6276 cmd += [executable] + executable_args
6281 @brief Extract numeric job id from standard sbatch output.
6282 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
6283 @return Value returned by `parse_slurm_job_id()`.
6285 match = re.search(
r"Submitted batch job\s+(\d+)", sbatch_output
or "")
6286 return match.group(1)
if match
else None
6288def submit_sbatch(script_path: str, dependency: str =
None, dependency_type: str =
"afterok") -> dict:
6290 @brief Submit sbatch script and return submission metadata.
6291 @param[in] script_path Argument passed to `submit_sbatch()`.
6292 @param[in] dependency Argument passed to `submit_sbatch()`.
6293 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
6294 @return Value returned by `submit_sbatch()`.
6298 cmd.append(f
"--dependency={dependency_type}:{dependency}")
6299 cmd.append(script_path)
6300 result = subprocess.run(cmd, text=
True, capture_output=
True, check=
False)
6303 "returncode": result.returncode,
6304 "stdout": (result.stdout
or "").strip(),
6305 "stderr": (result.stderr
or "").strip(),
6306 "script": script_path,
6308 if result.returncode != 0:
6309 print(f
"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
6310 sys.exit(result.returncode)
6312 if not metadata[
"job_id"]:
6314 f
"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
6323 @brief Prints validation errors and exits.
6324 @param[in] errors List of error message strings.
6326 print(f
"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
6327 for raw_error
in errors:
6333 "\nHint: See examples/master_template/ for valid config structure and "
6334 "docs/pages/14_Config_Contract.md for key-level contract details.",
6342 @brief Creates a standard header block for all generated files.
6343 @param[in] run_id The unique identifier for the current simulation run.
6344 @param[in] source_files A dictionary of source profile files used.
6345 @return A formatted string containing the header.
6348 "# ==============================================================================",
6349 "# AUTO-GENERATED CONFIGURATION FILE",
6350 "# ------------------------------------------------------------------------------",
6351 f
"# Run ID: {run_id}",
6352 f
"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
6354 "# Source Configuration:"
6356 for name, path
in source_files.items():
6357 header_parts.append(f
"# - {name:<12}: {os.path.basename(path)}")
6358 header_parts.extend([
6360 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
6361 "# ==============================================================================\n"
6363 return "\n".join(header_parts)
6367 @brief Generic function to create a file containing a simple list of strings.
6368 @param[in] run_dir The path to the main run directory.
6369 @param[in] run_id The unique identifier for the run.
6370 @param[in] cfg The dictionary containing the configuration data.
6371 @param[in] section The top-level key in the cfg dictionary.
6372 @param[in] key The second-level key whose value is the list of strings.
6373 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
6374 @param[in] header_sources A dictionary of source files for the header.
6375 @return The absolute path to the generated file.
6377 print(f
"[INFO] Generating {filename}...")
6378 config_dir = os.path.join(run_dir,
"config")
6379 file_path = os.path.join(config_dir, filename)
6382 items = cfg.get(section, {}).get(key, [])
6385 with open(file_path,
"w")
as f: f.write(
"\n".join(lines))
6386 print(f
"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
6387 return os.path.abspath(file_path)
6392 @brief Return True when logging.enabled_functions contains at least one entry.
6393 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
6394 @return Value returned by `has_explicit_monitor_whitelist()`.
6396 items = monitor_cfg.get(
"logging", {}).get(
"enabled_functions", [])
6402 @brief Resolve profiling reporting config from monitor.yml.
6403 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
6404 @return Value returned by `resolve_profiling_config()`.
6406 profiling_cfg = monitor_cfg.get(
"profiling", {})
or {}
6407 timestep_cfg = profiling_cfg.get(
"timestep_output")
6408 final_cfg = profiling_cfg.get(
"final_summary")
6410 if timestep_cfg
is None:
6413 timestep_file =
"Profiling_Timestep_Summary.csv"
6415 if not isinstance(timestep_cfg, dict):
6416 raise ValueError(
"monitor.profiling.timestep_output must be a mapping when provided.")
6417 mode = str(timestep_cfg.get(
"mode",
"off")).lower()
6418 functions = timestep_cfg.get(
"functions", [])
6419 timestep_file = str(timestep_cfg.get(
"file",
"Profiling_Timestep_Summary.csv"))
6421 if mode
not in {
"off",
"selected",
"all"}:
6422 raise ValueError(
"monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
6423 if functions
is None:
6425 if not isinstance(functions, list):
6426 raise ValueError(
"monitor.profiling.timestep_output.functions must be a list of function names.")
6427 if not all(isinstance(item, str)
and item.strip()
for item
in functions):
6428 raise ValueError(
"monitor.profiling.timestep_output.functions entries must be non-empty strings.")
6429 if mode ==
"selected" and not functions:
6430 raise ValueError(
"monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
6431 if mode !=
"selected" and functions:
6432 raise ValueError(
"monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
6433 if not timestep_file:
6434 raise ValueError(
"monitor.profiling.timestep_output.file must be a non-empty string.")
6436 if final_cfg
is None:
6437 final_enabled =
True
6438 elif isinstance(final_cfg, dict):
6439 final_enabled = bool(final_cfg.get(
"enabled",
True))
6441 raise ValueError(
"monitor.profiling.final_summary must be a mapping when provided.")
6445 "functions": functions,
6446 "timestep_file": timestep_file,
6447 "final_summary_enabled": final_enabled,
6451DIAGNOSTICS_PETSC_KEYS = {
6456 "malloc_view_threshold",
6469 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
6470 @param[in] value Candidate value.
6471 @param[in] key Diagnostics key used in error messages.
6472 @return Normalized value.
6474 if isinstance(value, bool)
or value
is None:
6476 if isinstance(value, str)
and value.strip():
6477 return value.strip()
6478 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
6483 @brief Validate a diagnostics boolean value.
6484 @param[in] value Candidate value.
6485 @param[in] key Diagnostics key used in error messages.
6486 @return Boolean value.
6488 if isinstance(value, bool):
6490 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean.")
6495 @brief Validate a diagnostics value that can be false, true, or "all".
6496 @param[in] value Candidate value.
6497 @param[in] key Diagnostics key used in error messages.
6498 @return Normalized value.
6500 if isinstance(value, bool)
or value
is None:
6502 if isinstance(value, str)
and value.strip().lower() ==
"all":
6504 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
6509 @brief Return an absolute run-local diagnostics file path.
6510 @param[in] run_dir Run directory.
6511 @param[in] filename Diagnostics filename.
6512 @return Absolute diagnostics path under the run logs directory.
6514 return os.path.abspath(os.path.join(run_dir,
"logs", filename))
6519 @brief Resolve true/string diagnostics values to a concrete file path.
6520 @param[in] value Boolean/string diagnostics value.
6521 @param[in] run_dir Run directory.
6522 @param[in] default_filename Default file name when value is true.
6523 @return False, or an absolute/explicit path string.
6527 if isinstance(value, str):
6528 if os.path.isabs(value)
or value.startswith(
":"):
6530 return os.path.abspath(os.path.join(run_dir,
"logs", value))
6536 @brief Resolve monitor diagnostics config and default run-local log paths.
6537 @param[in] monitor_cfg Parsed monitor.yml mapping.
6538 @param[in] run_dir Optional run directory for default artifact paths.
6539 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
6540 @return Normalized diagnostics config.
6542 diagnostics_cfg = (monitor_cfg.get(
"diagnostics", {})
or {})
if isinstance(monitor_cfg, dict)
else {}
6543 if not isinstance(diagnostics_cfg, dict):
6544 raise ValueError(
"monitor.diagnostics must be a mapping when provided.")
6546 petsc_raw = diagnostics_cfg.get(
"petsc", {})
or {}
6547 if not isinstance(petsc_raw, dict):
6548 raise ValueError(
"monitor.diagnostics.petsc must be a mapping when provided.")
6549 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
6551 raise ValueError(f
"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
6554 "malloc_debug":
_diagnostic_bool(petsc_raw.get(
"malloc_debug",
False),
"malloc_debug"),
6555 "malloc_test":
_diagnostic_bool(petsc_raw.get(
"malloc_test",
False),
"malloc_test"),
6556 "malloc_dump":
_diagnostic_bool(petsc_raw.get(
"malloc_dump",
False),
"malloc_dump"),
6558 "malloc_view_threshold": petsc_raw.get(
"malloc_view_threshold"),
6559 "memory_view":
_diagnostic_bool(petsc_raw.get(
"memory_view",
False),
"memory_view"),
6561 "log_view_memory":
_diagnostic_bool(petsc_raw.get(
"log_view_memory",
False),
"log_view_memory"),
6565 "options_left": petsc_raw.get(
"options_left"),
6567 if petsc[
"malloc_view_threshold"]
is not None and not isinstance(petsc[
"malloc_view_threshold"], (int, float)):
6568 raise ValueError(
"monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
6569 if petsc[
"options_left"]
is not None and not isinstance(petsc[
"options_left"], bool):
6570 raise ValueError(
"monitor.diagnostics.petsc.options_left must be boolean or null.")
6572 memory_raw = diagnostics_cfg.get(
"runtime_memory_log", {})
or {}
6573 if not isinstance(memory_raw, dict):
6574 raise ValueError(
"monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
6575 memory_unknown = sorted(set(memory_raw.keys()) - {
"enabled",
"file"})
6577 raise ValueError(f
"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
6578 memory_enabled = memory_raw.get(
"enabled",
True)
6579 if not isinstance(memory_enabled, bool):
6580 raise ValueError(
"monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
6581 memory_file = str(memory_raw.get(
"file",
"Runtime_Memory.log")).strip()
6583 raise ValueError(
"monitor.diagnostics.runtime_memory_log.file must be a non-empty string.")
6585 resolved_petsc = dict(petsc)
6588 suffix =
"PostProcessor" if stage_label ==
"PostProcessor" else "Solver"
6590 "malloc_view": f
"PETSc_MallocView_{suffix}.log",
6591 "log_view": f
"PETSc_LogView_{suffix}.log",
6592 "log_trace": f
"PETSc_LogTrace_{suffix}.log",
6594 for key, default_name
in defaults.items():
6596 if key ==
"log_view" and resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
6597 resolved_value = f
":{resolved_value}"
6598 resolved_petsc[key] = resolved_value
6599 if resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
6600 artifacts.append(resolved_value)
6601 elif resolved_value
and isinstance(resolved_value, str)
and resolved_value.startswith(
":"):
6602 artifacts.append(resolved_value[1:])
6604 artifacts.append(os.path.abspath(os.path.join(run_dir,
"logs", memory_file)))
6607 "petsc": resolved_petsc,
6608 "runtime_memory_log": {
"enabled": memory_enabled,
"file": memory_file},
6609 "artifacts": artifacts,
6615 @brief Build PETSc diagnostics command-line arguments for a run stage.
6616 @param[in] monitor_cfg Parsed monitor.yml mapping.
6617 @param[in] run_dir Run directory used to resolve default diagnostics files.
6618 @param[in] stage_label Stage label for default output names.
6619 @return List of executable arguments.
6622 petsc = diagnostics[
"petsc"]
6624 if petsc[
"malloc_debug"]:
6625 args.append(
"-malloc_debug")
6626 if petsc[
"malloc_test"]:
6627 args.append(
"-malloc_test")
6629 (
"malloc_dump",
"-malloc_dump"),
6630 (
"malloc_view",
"-malloc_view"),
6631 (
"memory_view",
"-memory_view"),
6632 (
"log_view",
"-log_view"),
6633 (
"log_trace",
"-log_trace"),
6634 (
"objects_dump",
"-objects_dump"),
6636 value = petsc.get(key)
6640 args.extend([flag, str(value)])
6641 if petsc[
"malloc_view_threshold"]
is not None:
6642 args.extend([
"-malloc_view_threshold", str(petsc[
"malloc_view_threshold"])])
6643 if petsc[
"log_view_memory"]:
6644 args.append(
"-log_view_memory")
6645 if petsc[
"log_all"]:
6646 args.append(
"-log_all")
6647 if petsc[
"options_left"]
is not None:
6648 args.extend([
"-options_left",
"true" if petsc[
"options_left"]
else "false"])
6654 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
6655 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
6656 @param[in] run_id Argument passed to `prepare_monitor_files()`.
6657 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
6658 @param[in] source_files Argument passed to `prepare_monitor_files()`.
6659 @return Value returned by `prepare_monitor_files()`.
6661 print(
"[INFO] Generating monitoring files...")
6663 whitelist_path =
None
6666 run_dir, run_id, monitor_cfg,
"logging",
"enabled_functions",
"whitelist.run", source_files
6669 print(
"[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
6674 if profiling_cfg[
"mode"] ==
"selected":
6678 {
"profiling": {
"selected_functions": profiling_cfg[
"functions"]}},
6680 "selected_functions",
6685 print(f
"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
6687 return {
"whitelist": whitelist_path,
"profile": profile_path,
"profiling": profiling_cfg}
6691 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
6692 and returns a list of their absolute paths.
6693 @details Handles both simple list format (for single-block cases) and a
6694 list-of-lists (for multi-block cases) for boundary conditions.
6695 @param[in] run_dir The path to the main run directory.
6696 @param[in] run_id The unique identifier for the run.
6697 @param[in] case_cfg The parsed case.yml configuration dictionary.
6698 @param[in] source_files A dictionary of source files for the header.
6699 @return A list of absolute paths to the generated BC files.
6700 @throws ValueError if the number of BC definitions does not match the number of blocks.
6702 print(
"[INFO] Generating boundary condition files...")
6703 config_dir = os.path.join(run_dir,
"config")
6704 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
6706 case_path = source_files.get(
"Case")
if source_files
else None
6707 profile_grid_dims =
None
6708 scales = case_cfg.get(
'properties', {}).get(
'scaling', {})
6709 U_ref =
_to_float(scales.get(
'velocity_ref'),
"properties.scaling.velocity_ref")
6711 raise ValueError(
"properties.scaling.velocity_ref must be non-zero for prescribed_flow profile staging.")
6713 if any(bc.get(
"handler") ==
"prescribed_flow" for block
in prepared_blocks
for bc
in block):
6716 generated_files = []
6717 generated_profile_summaries = []
6718 generated_target_grid =
None
6719 field_slice_target_grid =
None
6720 for i, block_bcs_list
in enumerate(prepared_blocks):
6721 file_name =
"bcs.run" if num_blocks == 1
else f
"bcs_block{i}.run"
6722 bcs_file_path = os.path.join(config_dir, file_name)
6725 for bc
in block_bcs_list:
6726 face, bc_type, handler = bc[
'face'], bc[
'type'], bc[
'handler']
6727 params = dict(bc.get(
'params')
or {})
6728 if handler ==
"prescribed_flow":
6729 source = params.pop(
"source")
6731 staged_name = f
"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
6732 staged_path = os.path.join(config_dir, staged_name)
6733 if source[
"type"] ==
"file":
6734 source_path = source[
"path"]
6735 if case_path
and not os.path.isabs(source_path):
6736 source_path = os.path.abspath(os.path.join(os.path.dirname(case_path), source_path))
6737 elif source[
"type"] ==
"generated":
6738 default_output = os.path.join(
6740 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.picslice",
6744 source.get(
"output_file"),
6746 default_to_config_dir=
True,
6748 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6750 f
"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
6752 if source[
"generator"] ==
"square_duct_poiseuille":
6753 if generated_target_grid
is None:
6759 target_grid=generated_target_grid,
6762 script=source.get(
"script"),
6763 case_path=case_path,
6766 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
6767 summary.update({
"block": i,
"face": face})
6768 generated_profile_summaries.append(summary)
6769 elif source[
"type"] ==
"field_slice":
6770 default_output = os.path.join(
6772 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.picslice",
6776 source.get(
"output_file"),
6778 default_to_config_dir=
True,
6780 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6782 f
"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
6784 if field_slice_target_grid
is None:
6790 field_slice_target_grid,
6795 summary.update({
"block": i,
"face": face})
6796 generated_profile_summaries.append(summary)
6798 raise ValueError(f
"Unsupported prescribed_flow source type '{source.get('type')}'.")
6801 f
"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
6802 f
"{os.path.relpath(staged_path)} dims={summary['dims']}"
6804 params[
"source_file"] = os.path.abspath(staged_path)
6808 for k, v
in params.items():
6809 if isinstance(v, bool):
6810 value_str =
"true" if v
else "false"
6813 parts.append(f
"{k}={value_str}")
6814 params_str =
" ".join(parts)
6815 bcs_lines.append(f
"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
6817 with open(bcs_file_path,
"w")
as f: f.write(
"\n".join(bcs_lines))
6819 print(f
"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
6820 generated_files.append(os.path.abspath(bcs_file_path))
6822 if generated_profile_summaries:
6824 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
6826 return generated_files
6830 @brief Converts Python types to C-style command-line flag values.
6831 @param[in] value The Python object to convert (bool, list, or other).
6832 @return A string representation suitable for a C command-line parser.
6834 if isinstance(value, bool):
6835 return "1" if value
else "0"
6836 if isinstance(value, list):
6837 return ",".join(map(str, value))
6842 @brief Return programmatic-grid settings translated to the C node-count contract.
6843 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
6844 @return Value returned by `translate_programmatic_grid_settings()`.
6846 translated = dict(grid_settings)
6847 for dim_key
in (
"im",
"jm",
"km"):
6848 if dim_key
in translated:
6849 raw_val = translated[dim_key]
6850 if not isinstance(raw_val, int)
or raw_val <= 0:
6852 f
"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
6853 f
"(got {raw_val!r})."
6855 translated[dim_key] = raw_val + 1
6859PROGRAMMATIC_IC_GEN_GRID_KEYS = (
6861 "xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
6862 "rxs",
"rys",
"rzs",
6868 @brief Validate scalar programmatic grid settings needed to materialize grid.run for ic_gen.
6869 @param[in] raw_settings programmatic_settings dict from case.yml.
6870 @throws ValueError when required scalar settings are missing or invalid.
6872 if not isinstance(raw_settings, dict):
6874 "grid.programmatic_settings must be a mapping for programmatic_c with generator 'ic_gen'."
6877 missing = [key
for key
in PROGRAMMATIC_IC_GEN_GRID_KEYS
if key
not in raw_settings]
6880 "grid.programmatic_settings must include "
6881 f
"{missing} when grid.mode is 'programmatic_c' and initial_conditions.generator is 'ic_gen'."
6884 for key
in (
"im",
"jm",
"km"):
6885 value = raw_settings[key]
6886 if isinstance(value, bool)
or not isinstance(value, int)
or value <= 0:
6888 f
"grid.programmatic_settings.{key} must be a positive scalar integer cell count "
6889 "for programmatic_c with generator 'ic_gen'."
6892 for key
in (
"xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
"rxs",
"rys",
"rzs"):
6893 value = raw_settings[key]
6894 if isinstance(value, (list, tuple, dict, bool)):
6896 f
"grid.programmatic_settings.{key} must be a scalar numeric value "
6897 "for programmatic_c with generator 'ic_gen'."
6900 numeric = float(value)
6901 except (TypeError, ValueError):
6903 f
"grid.programmatic_settings.{key} must be a scalar numeric value "
6904 "for programmatic_c with generator 'ic_gen'."
6906 if not math.isfinite(numeric):
6908 f
"grid.programmatic_settings.{key} must be finite "
6909 "for programmatic_c with generator 'ic_gen'."
6911 if key
in {
"rxs",
"rys",
"rzs"}
and numeric <= 0.0:
6913 f
"grid.programmatic_settings.{key} must be positive "
6914 "for programmatic_c with generator 'ic_gen'."
6920 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
6921 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
6922 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
6923 @param[in] raw_settings programmatic_settings dict from case.yml.
6924 @param[in] dest_path Destination PICGRID file path.
6925 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
6926 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
6930 raise ValueError(
"length_ref must be non-zero for programmatic grid generation.")
6931 IM = int(raw_settings.get(
"im", 0)) + 1
6932 JM = int(raw_settings.get(
"jm", 0)) + 1
6933 KM = int(raw_settings.get(
"km", 0)) + 1
6934 if IM < 2
or JM < 2
or KM < 2:
6936 f
"programmatic_settings im/jm/km must each be >= 1 "
6937 f
"(got im={IM-1}, jm={JM-1}, km={KM-1})."
6939 x_min = float(raw_settings.get(
"xMins", 0.0))
6940 x_max = float(raw_settings.get(
"xMaxs", 1.0))
6941 y_min = float(raw_settings.get(
"yMins", 0.0))
6942 y_max = float(raw_settings.get(
"yMaxs", 1.0))
6943 z_min = float(raw_settings.get(
"zMins", 0.0))
6944 z_max = float(raw_settings.get(
"zMaxs", 1.0))
6945 rx = float(raw_settings.get(
"rxs", 1.0))
6946 ry = float(raw_settings.get(
"rys", 1.0))
6947 rz = float(raw_settings.get(
"rzs", 1.0))
6949 def _stretched(idx, N, length, r):
6951 @brief Mirror of ComputeStretchedCoord from src/grid.c.
6952 @param[in] idx Node index along the axis.
6953 @param[in] N Total node count along the axis.
6954 @param[in] length Physical length of the axis.
6955 @param[in] r Geometric stretching ratio.
6956 @return Coordinate offset from the axis minimum.
6958 frac = idx / (N - 1.0)
6959 if abs(r - 1.0) < 1.0e-9:
6960 return length * frac
6961 return length * (r ** frac - 1.0) / (r - 1.0)
6963 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
6964 os.makedirs(os.path.dirname(dest_path), exist_ok=
True)
6965 with open(dest_path,
"w")
as fout:
6966 fout.write(
"PICGRID\n1\n")
6967 fout.write(f
"{IM} {JM} {KM}\n")
6969 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
6971 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
6973 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
6974 fout.write(f
"{x:.8e} {y:.8e} {z:.8e}\n")
6975 total_nodes = IM * JM * KM
6976 return {
"nblk": 1,
"dims": [(IM, JM, KM)],
"total_nodes": total_nodes}
6979GRID_DA_PROCESSOR_KEYS = (
"da_processors_x",
"da_processors_y",
"da_processors_z")
6984 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
6985 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
6986 @return Value returned by `resolve_grid_da_processor_layout()`.
6991 for key
in GRID_DA_PROCESSOR_KEYS:
6992 value = grid_cfg.get(key)
6993 if isinstance(value, (list, tuple)):
6995 f
"grid.{key} must be a scalar integer. "
6996 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
6998 if value
is not None:
6999 if not isinstance(value, int)
or value <= 0:
7000 raise ValueError(f
"grid.{key} must be a positive integer when provided (got {value}).")
7001 top_level[key] = value
7003 legacy_settings = grid_cfg.get(
"programmatic_settings")
7004 if isinstance(legacy_settings, dict):
7005 for key
in GRID_DA_PROCESSOR_KEYS:
7006 value = legacy_settings.get(key)
7007 if isinstance(value, (list, tuple)):
7009 f
"grid.programmatic_settings.{key} must be a scalar integer. "
7010 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
7012 if value
is not None:
7013 if not isinstance(value, int)
or value <= 0:
7015 f
"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
7020 for key
in GRID_DA_PROCESSOR_KEYS:
7021 top_value = top_level.get(key)
7022 legacy_value = legacy.get(key)
7023 if top_value
is not None and legacy_value
is not None and top_value != legacy_value:
7025 f
"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
7026 "define the processor layout in only one place."
7028 if top_value
is not None:
7029 resolved[key] = top_value
7030 elif legacy_value
is not None:
7031 resolved[key] = legacy_value
7038 @brief Append optional global DMDA layout flags for any grid mode.
7039 @param[in] control_lines Argument passed to `append_grid_da_processor_layout()`.
7040 @param[in] grid_cfg Argument passed to `append_grid_da_processor_layout()`.
7041 @param[in] num_procs Argument passed to `append_grid_da_processor_layout()`.
7046 print(
"[INFO] Letting PETSc automatically determine processor layout.")
7050 print(
"[INFO] Serial run, ignoring da_processors layout.")
7053 if all(layout.get(key)
is not None for key
in GRID_DA_PROCESSOR_KEYS):
7055 for key
in GRID_DA_PROCESSOR_KEYS:
7056 total_layout *= layout[key]
7057 if total_layout != num_procs:
7058 raise ValueError(f
"Processor layout mismatch: product ({total_layout}) != processes ({num_procs}).")
7059 print(f
"[INFO] Applying user-defined processor layout for {num_procs} processes.")
7061 printable =
" x ".join(str(layout.get(key,
"PETSC_DECIDE"))
for key
in GRID_DA_PROCESSOR_KEYS)
7062 print(f
"[INFO] Applying partial processor layout: {printable}.")
7064 for key
in GRID_DA_PROCESSOR_KEYS:
7065 value = layout.get(key)
7066 if value
is not None:
7067 control_lines.append(f
"-{key} {value}")
7071 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
7072 @param[in] value Canonical momentum solver string from YAML.
7073 @return Canonical value accepted by -mom_solver_type.
7074 @throws ValueError if the input cannot be mapped.
7078 raise ValueError(
"momentum solver type cannot be None")
7080 raw = str(value).strip()
7082 "Explicit RK4":
"EXPLICIT_RK",
7083 "Dual Time Picard Jameson RK":
"DUALTIME_PICARD_JAMESON_RK",
7084 "Dual Time Picard RK4":
"DUALTIME_PICARD_JAMESON_RK",
7085 "Newton Krylov":
"newton_krylov",
7089 f
"Unknown momentum solver '{value}'. Use one of: "
7090 "'Explicit RK4', 'Dual Time Picard Jameson RK', 'Newton Krylov'."
7097 @brief Validate and normalize the structured Newton--Krylov solver block.
7098 @param[in] cfg Structured `momentum_solver.newton_krylov` mapping.
7099 @return Normalized copy containing only supported structured fields.
7101 root =
"momentum_solver.newton_krylov"
7102 if not isinstance(cfg, dict):
7103 raise ValueError(f
"{root} must be a mapping.")
7105 unknown = sorted(set(cfg) - {
"nonlinear_solver",
"linear_solver"})
7107 raise ValueError(f
"{root} has unsupported key(s): {unknown}.")
7111 def _mapping(parent: dict, key: str, path: str) -> dict:
7113 @brief Read and validate one optional nested Newton mapping.
7114 @param[in] parent Parent mapping.
7115 @param[in] key Nested key to read.
7116 @param[in] path User-facing YAML path for errors.
7117 @return Nested mapping, or an empty mapping when omitted.
7119 value = parent.get(key, {})
7120 if value
is None or not isinstance(value, dict):
7121 raise ValueError(f
"{path} must be a mapping when provided.")
7124 def _method(value, path: str) -> str:
7126 @brief Normalize one nonempty PETSc solver/type token.
7127 @param[in] value YAML token value.
7128 @param[in] path User-facing YAML path for errors.
7129 @return Lowercase PETSc token.
7131 if not isinstance(value, str)
or not value.strip():
7132 raise ValueError(f
"{path} must be a non-empty string.")
7133 return value.strip().lower()
7135 def _tolerance(value, path: str):
7137 @brief Validate one finite nonnegative tolerance.
7138 @param[in] value YAML tolerance value.
7139 @param[in] path User-facing YAML path for errors.
7140 @return Original validated value.
7142 if isinstance(value, bool):
7143 raise ValueError(f
"{path} must be numeric and nonnegative.")
7145 numeric = float(value)
7146 except (TypeError, ValueError)
as exc:
7147 raise ValueError(f
"{path} must be numeric and nonnegative.")
from exc
7148 if not math.isfinite(numeric)
or numeric < 0.0:
7149 raise ValueError(f
"{path} must be numeric and nonnegative.")
7152 def _positive_integer(value, path: str):
7154 @brief Validate one positive integer count.
7155 @param[in] value YAML count value.
7156 @param[in] path User-facing YAML path for errors.
7157 @return Original validated integer.
7159 if isinstance(value, bool)
or not isinstance(value, int)
or value <= 0:
7160 raise ValueError(f
"{path} must be a positive integer.")
7163 nonlinear = _mapping(cfg,
"nonlinear_solver", f
"{root}.nonlinear_solver")
7164 nonlinear_path = f
"{root}.nonlinear_solver"
7165 unknown = sorted(set(nonlinear) - {
7166 "method",
"absolute_tolerance",
"relative_tolerance",
"step_tolerance",
7167 "max_iterations",
"line_search",
7170 raise ValueError(f
"{nonlinear_path} has unsupported key(s): {unknown}.")
7172 if "method" in nonlinear:
7173 nonlinear_out[
"method"] = _method(nonlinear[
"method"], f
"{nonlinear_path}.method")
7174 for key
in (
"absolute_tolerance",
"relative_tolerance",
"step_tolerance"):
7175 if key
in nonlinear:
7176 nonlinear_out[key] = _tolerance(nonlinear[key], f
"{nonlinear_path}.{key}")
7177 if "max_iterations" in nonlinear:
7178 nonlinear_out[
"max_iterations"] = _positive_integer(
7179 nonlinear[
"max_iterations"], f
"{nonlinear_path}.max_iterations"
7181 if "line_search" in nonlinear:
7182 line_search = _mapping(nonlinear,
"line_search", f
"{nonlinear_path}.line_search")
7183 unknown = sorted(set(line_search) - {
"type"})
7185 raise ValueError(f
"{nonlinear_path}.line_search has unsupported key(s): {unknown}.")
7186 nonlinear_out[
"line_search"] = {}
7187 if "type" in line_search:
7188 nonlinear_out[
"line_search"][
"type"] = _method(
7189 line_search[
"type"], f
"{nonlinear_path}.line_search.type"
7191 normalized[
"nonlinear_solver"] = nonlinear_out
7193 linear = _mapping(cfg,
"linear_solver", f
"{root}.linear_solver")
7194 linear_path = f
"{root}.linear_solver"
7195 unknown = sorted(set(linear) - {
7196 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
7197 "gmres",
"preconditioner",
7200 raise ValueError(f
"{linear_path} has unsupported key(s): {unknown}.")
7203 if "method" in linear:
7204 method = _method(linear[
"method"], f
"{linear_path}.method")
7205 linear_out[
"method"] = method
7206 for key
in (
"absolute_tolerance",
"relative_tolerance"):
7208 linear_out[key] = _tolerance(linear[key], f
"{linear_path}.{key}")
7209 if "max_iterations" in linear:
7210 linear_out[
"max_iterations"] = _positive_integer(
7211 linear[
"max_iterations"], f
"{linear_path}.max_iterations"
7213 if "gmres" in linear:
7214 gmres = _mapping(linear,
"gmres", f
"{linear_path}.gmres")
7215 unknown = sorted(set(gmres) - {
"restart"})
7217 raise ValueError(f
"{linear_path}.gmres has unsupported key(s): {unknown}.")
7218 linear_out[
"gmres"] = {}
7219 if "restart" in gmres:
7220 if method
not in {
"gmres",
"fgmres",
"lgmres"}:
7222 f
"{linear_path}.gmres.restart is valid only when {linear_path}.method "
7223 "is one of 'gmres', 'fgmres', or 'lgmres'."
7225 linear_out[
"gmres"][
"restart"] = _positive_integer(
7226 gmres[
"restart"], f
"{linear_path}.gmres.restart"
7228 if "preconditioner" in linear:
7229 preconditioner = _mapping(linear,
"preconditioner", f
"{linear_path}.preconditioner")
7230 unknown = sorted(set(preconditioner) - {
"type"})
7232 raise ValueError(f
"{linear_path}.preconditioner has unsupported key(s): {unknown}.")
7233 linear_out[
"preconditioner"] = {}
7234 if "type" in preconditioner:
7235 pc_type = _method(preconditioner[
"type"], f
"{linear_path}.preconditioner.type")
7236 if pc_type !=
"none":
7238 f
"{linear_path}.preconditioner.type currently supports only 'none'."
7240 linear_out[
"preconditioner"][
"type"] = pc_type
7241 normalized[
"linear_solver"] = linear_out
7246 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
7247 @param[in] value Human-readable solution-convergence mode selector.
7248 @return Canonical string accepted by `-solution_convergence_mode`.
7249 @throws ValueError if the input cannot be mapped.
7252 raise ValueError(
"solution_convergence.mode cannot be None")
7254 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7256 "steady_deterministic":
"STEADY_DETERMINISTIC",
7257 "periodic_deterministic":
"PERIODIC_DETERMINISTIC",
7258 "statistical_steady":
"STATISTICAL_STEADY",
7259 "transient":
"TRANSIENT",
7261 mapped = aliases.get(normalized)
7264 f
"Unknown solution_convergence.mode '{value}'. Use one of: "
7265 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
7271 @brief Maps canonical field init mode names to C enum/int codes (-finit).
7272 @param[in] value Canonical field initialization mode.
7273 @return Canonical integer code accepted by -finit.
7274 @throws ValueError if the input cannot be mapped.
7278 raise ValueError(
"field initialization mode cannot be None")
7284 }.get(str(value).strip())
7287 f
"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
7293 @brief Normalize a file IC field selector to its staged basename and C enum value.
7294 @param[in] value User-facing Ucat or Ucont selector.
7295 @return Tuple of staged field basename and C enum value.
7297 normalized = str(value
or "").strip().lower()
7298 if normalized ==
"ucat":
7300 if normalized ==
"ucont":
7302 raise ValueError(
"initial_conditions.field must be 'Ucat' or 'Ucont'.")
7306 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
7307 @param[in] ic Initial-condition YAML mapping.
7308 @param[in] prepared_blocks Normalized boundary-condition blocks.
7309 @param[in] U_ref Physical reference velocity.
7310 @return Normalized launcher initial-condition contract.
7312 if not isinstance(ic, dict):
7313 raise ValueError(
"properties.initial_conditions must be a mapping.")
7314 mode = str(ic.get(
"mode",
"")).strip()
7317 if mode
in {
"Zero",
"Constant",
"Poiseuille"}:
7320 if finit_code == 1
and params.pop(
"ic_coordinate_system", 0) == 1:
7322 return {
"finit": finit_code,
"cli_params": params,
"kind":
"builtin",
"label": mode}
7324 normalized_mode = mode.lower().replace(
"-",
"_").replace(
" ",
"_")
7325 if normalized_mode ==
"file":
7326 if prepared_blocks
and len(prepared_blocks) > 1:
7327 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
7328 source_file = ic.get(
"source_file")
7329 if not isinstance(source_file, str)
or not source_file.strip():
7330 raise ValueError(
"initial_conditions.source_file is required when mode is 'file'.")
7333 "finit": 4,
"cli_params": {},
"kind":
"file",
"label":
"file",
7334 "source_file": source_file.strip(),
"field_name": field_name,
"field_code": field_code,
7336 if normalized_mode !=
"generated":
7337 raise ValueError(
"initial_conditions.mode must be 'generated' or 'file'.")
7339 generator = str(ic.get(
"generator",
"")).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7340 params = ic.get(
"params", {})
7341 if not isinstance(params, dict):
7342 raise ValueError(
"initial_conditions.params must be a mapping.")
7343 if generator ==
"ic_gen":
7344 if prepared_blocks
and len(prepared_blocks) > 1:
7345 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
7346 script = params.get(
"script")
7347 if script
is not None and (
not isinstance(script, str)
or not script.strip()):
7348 raise ValueError(
"initial_conditions.params.script must be a non-empty path when provided.")
7350 config_file = params.get(
"config_file")
7351 if not isinstance(config_file, str)
or not config_file.strip():
7352 raise ValueError(
"initial_conditions.params.config_file is required for generator 'ic_gen'.")
7353 cli_args = params.get(
"cli_args", [])
7354 if cli_args
is None:
7356 if not isinstance(cli_args, list):
7357 raise ValueError(
"initial_conditions.params.cli_args must be a list.")
7359 "finit": 4,
"cli_params": {},
"kind":
"ic_gen",
"label":
"ic_gen",
7360 "field_name": field_name,
"field_code": field_code,
7361 "config_file": config_file.strip(),
7362 "script": script.strip()
if script
is not None else None,
7363 "output_file": params.get(
"output_file"),
7364 "cli_args": cli_args,
7368 "zero": (0,
"Zero"),
7369 "constant": (1,
"Constant"),
7370 "streamwise_constant": (3,
"Constant"),
7371 "poiseuille": (2,
"Poiseuille"),
7373 if generator
not in generator_modes:
7375 "initial_conditions.generator must be one of: zero, constant, "
7376 "streamwise_constant, poiseuille, ic_gen."
7378 finit_code, legacy_mode = generator_modes[generator]
7379 legacy_ic = dict(params)
7380 legacy_ic[
"mode"] = legacy_mode
7383 1
if finit_code == 3
else finit_code,
7387 cli_params.pop(
"ic_coordinate_system",
None)
7388 return {
"finit": finit_code,
"cli_params": cli_params,
"kind":
"builtin",
"label": generator}
7392 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
7393 @param[in] path PETSc binary vector path.
7394 @return Summary containing the absolute path and scalar count.
7397 with open(path,
"rb")
as fin:
7398 header = fin.read(8)
7399 if len(header) != 8:
7400 raise ValueError(f
"PETSc Vec file is too short: {path}")
7401 class_id, scalar_count = struct.unpack(
">ii", header)
7402 if class_id != 1211214
or scalar_count < 0:
7403 raise ValueError(f
"Invalid PETSc Vec header in {path}.")
7404 payload = fin.read()
7405 if len(payload) != scalar_count * 8:
7407 f
"PETSc Vec payload size mismatch in {path}: expected {scalar_count * 8} bytes, found {len(payload)}."
7409 return {
"path": os.path.abspath(path),
"scalar_count": scalar_count}
7413 @brief Run the repository IC generator.
7414 @param[in] case_path Source case YAML path.
7415 @param[in] run_dir Run or precompute output directory.
7416 @param[in] resolved_ic Normalized external-generator contract.
7417 @return Generated PETSc vector path.
7419 case_dir = os.path.dirname(os.path.abspath(case_path))
7421 config_file = resolved_ic[
"config_file"]
7422 config_file = config_file
if os.path.isabs(config_file)
else os.path.abspath(os.path.join(case_dir, config_file))
7423 if not os.path.isfile(script):
7424 raise ValueError(f
"ic.gen script not found: {script}")
7425 if not os.path.isfile(config_file):
7426 raise ValueError(f
"initial-condition generator config file not found: {config_file}")
7427 default_output = os.path.join(
"config",
"initial_condition.generated.dat")
7429 run_dir, resolved_ic.get(
"output_file"), default_output, default_to_config_dir=
True
7431 os.makedirs(os.path.dirname(output_path), exist_ok=
True)
7432 cmd = [sys.executable, script,
"-c", config_file,
"--field",
7433 "Ucat" if resolved_ic[
"field_code"] == 0
else "Ucont",
7434 "--output", output_path]
7435 staged_grid = os.path.join(run_dir,
"config",
"grid.run")
7436 if os.path.isfile(staged_grid):
7437 cmd.extend([
"--grid", staged_grid])
7438 cmd.extend(str(token)
for token
in resolved_ic.get(
"cli_args", []))
7439 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
7440 if result.returncode != 0:
7441 details = (result.stderr
or result.stdout
or "").strip()
7442 raise ValueError(f
"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
7448 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
7449 @param[in] run_dir Run or precompute output directory.
7450 @param[in] case_path Source case YAML path.
7451 @param[in] resolved_ic Normalized file-backed IC contract.
7452 @return Source, staged path, and staging-directory summary.
7454 if resolved_ic[
"kind"] ==
"ic_gen":
7457 source_path = resolved_ic[
"source_file"]
7458 if not os.path.isabs(source_path):
7459 source_path = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(case_path)), source_path))
7460 if not os.path.isfile(source_path):
7461 raise ValueError(f
"Initial-condition source file not found: {source_path}")
7463 stage_dir = os.path.join(run_dir,
"config",
"initial_condition")
7464 os.makedirs(stage_dir, exist_ok=
True)
7465 staged_path = os.path.join(stage_dir, f
"{resolved_ic['field_name']}00000_0.dat")
7466 if os.path.abspath(source_path) != os.path.abspath(staged_path):
7467 shutil.copy2(source_path, staged_path)
7468 return {
"source": os.path.abspath(source_path),
"staged": os.path.abspath(staged_path),
"directory": os.path.abspath(stage_dir)}
7472 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
7473 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
7474 @return Integer 0-5 matching the FlowDirection enum.
7475 @throws ValueError on unknown value.
7479 "+Eta": 2,
"-Eta": 3,
7480 "+Zeta": 4,
"-Zeta": 5,
7481 }.get(str(value).strip())
7484 f
"Unknown initial_conditions.flow_direction '{value}'. "
7485 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
7491 @brief Return True if any prepared BC block contains an INLET face.
7492 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
7493 @return True if at least one INLET entry exists across all blocks.
7495 if not prepared_blocks:
7497 for block_bcs
in prepared_blocks:
7498 for entry
in block_bcs:
7499 if entry.get(
"type") ==
"INLET":
7505 @brief Resolve all IC parameters and return a dict of PETSc option values.
7506 @param[in] ic The properties.initial_conditions mapping.
7507 @param[in] finit_code Normalized -finit integer code.
7508 @param[in] prepared_blocks Normalized BC blocks (may be None).
7509 @param[in] U_ref Reference velocity for non-dimensionalization.
7510 @return Dict with keys matching PETSc option names (without leading dash).
7511 @throws KeyError if a required key is absent.
7512 @throws ValueError on invalid combinations or values.
7520 has_cartesian = any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical"))
7521 has_curvilinear =
"velocity_physical" in ic
7523 if has_cartesian
and has_curvilinear:
7525 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
7526 "velocity_physical (curvilinear) — use one or the other."
7531 result[
"ic_coordinate_system"] = cs_code
7533 vel_phys = float(ic[
"velocity_physical"])
7534 except (TypeError, ValueError)
as exc:
7536 f
"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
7537 "Expected a numeric value."
7539 result[
"ic_velocity_physical"] = vel_phys / U_ref
if U_ref != 0
else 0.0
7541 if "flow_direction" in ic:
7545 "initial_conditions.flow_direction is required for curvilinear Constant IC "
7546 "when no INLET face exists."
7550 if "flow_direction" in ic:
7552 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
7553 "Use velocity_physical + flow_direction for curvilinear mode."
7556 result[
"ic_coordinate_system"] = cs_code
7558 scale = 1.0 / U_ref
if U_ref != 0
else 0.0
7559 result[
"ucont_x"] = u * scale
7560 result[
"ucont_y"] = v * scale
7561 result[
"ucont_z"] = w * scale
7563 elif finit_code == 2:
7564 if any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical")):
7566 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
7568 if "velocity_physical" in ic:
7570 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
7572 if "peak_velocity_physical" not in ic:
7573 raise KeyError(
"peak_velocity_physical")
7575 peak = float(ic[
"peak_velocity_physical"])
7576 except (TypeError, ValueError)
as exc:
7578 f
"Invalid value for initial_conditions.peak_velocity_physical: "
7579 f
"{ic['peak_velocity_physical']!r}. Expected a numeric value."
7581 result[
"ic_velocity_physical"] = peak / U_ref
if U_ref != 0
else 0.0
7583 if "flow_direction" in ic:
7588 fd_axis_name = {0:
"x", 1:
"y", 2:
"z"}.get(fd_int // 2,
"?")
7589 if inlet_axis
and fd_axis_name != inlet_axis:
7590 token = ic[
"flow_direction"]
7592 f
"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
7593 f
"does not match INLET face axis '{inlet_axis}'."
7595 result[
"flow_direction"] = fd_int
7598 "initial_conditions.flow_direction is required for Poiseuille IC "
7599 "when no INLET face exists."
7606 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
7607 @param[in] value Human-readable or enum-like Eulerian field source.
7608 @return Canonical string accepted by `-euler_field_source`.
7609 @throws ValueError if the input cannot be mapped.
7612 raise ValueError(
"eulerian_field_source cannot be None")
7614 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7618 "analytical":
"analytical",
7620 mapped = aliases.get(normalized)
7623 f
"Unknown operation_mode.eulerian_field_source '{value}'. "
7624 "Use one of: 'solve', 'load', 'analytical'."
7630 @brief Normalizes the analytical solution selector to the C-side canonical string.
7631 @param[in] value Human-readable analytical solution selector.
7632 @return Canonical string accepted by `-analytical_type`.
7633 @throws ValueError if the input cannot be mapped.
7637 raise ValueError(
"analytical_type cannot be None")
7639 normalized = str(value).strip().upper().replace(
"-",
"_").replace(
" ",
"_")
7640 if normalized
not in {
"TGV3D",
"ZERO_FLOW",
"UNIFORM_FLOW"}:
7642 f
"Unknown operation_mode.analytical_type '{value}'. "
7643 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
7649 @brief Parse initial-condition velocity components with mode-aware defaults.
7650 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
7651 @param[in] finit_code Normalized `-finit` integer code.
7652 @param[in] require_explicit If True, all three component keys must be present.
7653 @return Tuple `(u, v, w)` in physical units.
7654 @throws KeyError if a required component key is missing.
7655 @throws ValueError if a component cannot be converted to float.
7657 component_keys = (
"u_physical",
"v_physical",
"w_physical")
7659 for key
in component_keys:
7660 if key
not in initial_conditions:
7661 if require_explicit:
7665 raw_value = initial_conditions[key]
7667 components.append(float(raw_value))
7668 except (TypeError, ValueError)
as exc:
7670 f
"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
7672 return tuple(components)
7676 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
7677 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
7678 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
7679 @throws ValueError if different blocks imply different inlet axes.
7681 face_order = (
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta")
7683 "-Xi":
"x",
"+Xi":
"x",
7684 "-Eta":
"y",
"+Eta":
"y",
7685 "-Zeta":
"z",
"+Zeta":
"z",
7689 for block_bcs
in prepared_blocks:
7690 face_map = {entry[
"face"]: entry
for entry
in block_bcs}
7691 for face
in face_order:
7692 entry = face_map.get(face)
7693 if entry
and entry[
"type"] ==
"INLET":
7694 inlet_axes.add(face_axis[face])
7699 if len(inlet_axes) != 1:
7701 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
7702 f
"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
7704 return next(iter(inlet_axes))
7708 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
7709 @param[in] value Canonical particle initialization mode.
7710 @return Canonical integer code accepted by -pinit.
7711 @throws ValueError if the input cannot be mapped.
7715 raise ValueError(
"particle init mode cannot be None")
7722 }.get(str(value).strip())
7725 f
"Unknown particle init_mode '{value}'. Use one of: "
7726 "'Surface', 'Volume', 'PointSource', 'SurfaceEdges'."
7732 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
7733 @param[in] value Canonical interpolation method name.
7734 @return Integer code accepted by -interpolation_method.
7735 @throws ValueError if the input cannot be mapped.
7738 raise ValueError(
"interpolation method cannot be None")
7742 "CornerAveraged": 1,
7743 }.get(str(value).strip())
7746 f
"Unknown interpolation_method '{value}'. Use one of: "
7747 "'Trilinear', 'CornerAveraged'."
7753 @brief Maps LES model selectors to C enum/int codes (-les).
7754 @param[in] value LES selector name or legacy integer/bool value.
7755 @return Integer code accepted by -les.
7756 @throws ValueError if the input cannot be mapped.
7758 if isinstance(value, bool):
7759 return 1
if value
else 0
7760 if isinstance(value, int):
7761 if value
in (0, 1, 2):
7763 raise ValueError(
"models.physics.turbulence.les must be 0, 1, 2, false/true, or a supported model block.")
7765 raise ValueError(
"LES model cannot be None")
7767 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7774 "constant_smagorinsky": 1,
7777 "dynamic_smagorinsky": 2,
7781 f
"Unknown LES model '{value}'. Use one of: 'none', "
7782 "'constant_smagorinsky', 'dynamic_smagorinsky'."
7788 @brief Maps LES test-filter names to the C -testfilter_ik flag.
7789 @param[in] value Test-filter selector name or legacy integer/bool value.
7790 @return 0 for volume-weighted box, 1 for homogeneous i/k Simpson filtering.
7791 @throws ValueError if the input cannot be mapped.
7793 if isinstance(value, bool):
7794 return 1
if value
else 0
7795 if isinstance(value, int):
7798 raise ValueError(
"models.physics.turbulence.les.test_filter must be 0, 1, or a supported filter name.")
7800 raise ValueError(
"LES test_filter cannot be None")
7802 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7804 "volume_weighted_box": 0,
7807 "homogeneous_ik": 1,
7808 "ik_homogeneous": 1,
7813 f
"Unknown LES test_filter '{value}'. Use one of: "
7814 "'volume_weighted_box', 'homogeneous_ik'."
7820 @brief Maps RANS model selectors to the current C -rans switch.
7821 @param[in] value RANS selector name or legacy integer/bool value.
7822 @return Integer code accepted by -rans.
7823 @throws ValueError if the input cannot be mapped.
7825 if isinstance(value, bool):
7826 return 1
if value
else 0
7827 if isinstance(value, int):
7830 raise ValueError(
"models.physics.turbulence.rans must be 0, 1, false/true, or a supported model block.")
7832 raise ValueError(
"RANS model cannot be None")
7834 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7843 raise ValueError(f
"Unknown RANS model '{value}'. Use one of: 'none', 'k_omega'.")
7848 @brief Validates wall-function model selectors exposed in YAML.
7849 @param[in] value Wall-function selector name.
7850 @return Canonical wall-function model name.
7851 @throws ValueError if the input cannot be mapped.
7855 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7856 if key
in {
"log_law",
"loglaw"}:
7858 raise ValueError(
"Unknown wall_function model '%s'. Use: 'log_law'." % value)
7862 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
7863 @param[in] cfg Mapping that may contain `enabled`.
7864 @param[in] path Human-readable config path for diagnostics.
7865 @param[in] default Value used when `enabled` is omitted.
7866 @return Boolean enabled state.
7867 @throws ValueError if `enabled` is not a YAML boolean.
7869 if 'enabled' not in cfg:
7871 if not isinstance(cfg[
'enabled'], bool):
7872 raise ValueError(f
"{path}.enabled must be true or false.")
7873 return cfg[
'enabled']
7877 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
7878 @param[in] models Parsed case.yml `models` mapping.
7879 @param[out] control_lines A list of strings to which C-flags will be appended.
7881 turbulence_cfg = models.get(
'physics', {}).get(
'turbulence', {})
7882 if not turbulence_cfg:
7884 if not isinstance(turbulence_cfg, dict):
7885 raise ValueError(
"models.physics.turbulence must be a mapping.")
7887 les_cfg = turbulence_cfg.get(
'les')
7888 rans_cfg = turbulence_cfg.get(
'rans')
7889 wall_cfg = turbulence_cfg.get(
'wall_function')
7893 if isinstance(les_cfg, dict):
7895 model_value = les_cfg.get(
'model',
'constant_smagorinsky')
7897 control_lines.append(f
"-les {les_code}")
7898 if 'constant_cs' in les_cfg:
7899 control_lines.append(f
"-const_cs {format_flag_value(les_cfg['constant_cs'])}")
7900 if 'max_cs' in les_cfg:
7901 control_lines.append(f
"-max_cs {format_flag_value(les_cfg['max_cs'])}")
7902 if 'dynamic_frequency' in les_cfg:
7903 control_lines.append(f
"-dynamic_freq {format_flag_value(les_cfg['dynamic_frequency'])}")
7904 if 'test_filter' in les_cfg:
7905 control_lines.append(f
"-testfilter_ik {normalize_les_test_filter(les_cfg['test_filter'])}")
7906 elif les_cfg
is not None:
7908 control_lines.append(f
"-les {les_code}")
7910 if isinstance(rans_cfg, dict):
7912 model_value = rans_cfg.get(
'model',
'k_omega')
7914 control_lines.append(f
"-rans {rans_code}")
7915 elif rans_cfg
is not None:
7917 control_lines.append(f
"-rans {rans_code}")
7919 if les_code
and rans_code:
7920 raise ValueError(
"models.physics.turbulence cannot enable both LES and RANS in the same case.")
7922 if isinstance(wall_cfg, dict):
7925 control_lines.append(f
"-wallfunction {1 if enabled else 0}")
7926 if 'roughness_height' in wall_cfg:
7927 control_lines.append(f
"-wall_roughness {format_flag_value(wall_cfg['roughness_height'])}")
7928 elif wall_cfg
is not None:
7929 control_lines.append(f
"-wallfunction {format_flag_value(wall_cfg)}")
7933 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
7934 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
7935 is skipped. All other values are emitted as "<flag> <value>".
7936 @param[out] control_lines The destination list of control-file lines.
7937 @param[in] options Mapping of raw CLI flags to values.
7941 for flag, value
in options.items():
7942 if isinstance(value, bool):
7944 control_lines.append(str(flag))
7946 control_lines.append(f
"{flag} {format_flag_value(value)}")
7949SOLVER_MONITORING_POISSON_FLAG_MAP = {
7950 "pic_true_residual":
"-ps_ksp_pic_monitor_true_residual",
7951 "true_residual":
"-ps_ksp_monitor_true_residual",
7952 "converged_reason":
"-ps_ksp_converged_reason",
7953 "view":
"-ps_ksp_view",
7956SOLVER_MONITORING_MOMENTUM_FLAG_MAP = {
7957 "newton_krylov_history":
"-mom_nk_pic_monitor",
7958 "snes_monitor":
"-mom_nk_snes_monitor",
7959 "snes_converged_reason":
"-mom_nk_snes_converged_reason",
7960 "ksp_monitor":
"-mom_nk_ksp_monitor",
7961 "ksp_converged_reason":
"-mom_nk_ksp_converged_reason",
7967 @brief Resolve human-readable solver monitoring YAML to raw control flags.
7968 @param[in] monitor_cfg Parsed monitor.yml mapping.
7969 @return Mapping of raw C/PETSc flags to values.
7971 solver_mon_cfg = monitor_cfg.get(
"solver_monitoring", {})
if isinstance(monitor_cfg, dict)
else {}
7972 if solver_mon_cfg
is None:
7974 if not isinstance(solver_mon_cfg, dict):
7975 raise ValueError(
"monitor.solver_monitoring must be a mapping when provided.")
7979 momentum_cfg = solver_mon_cfg.get(
"momentum", {})
7980 if momentum_cfg
is None:
7982 if not isinstance(momentum_cfg, dict):
7983 raise ValueError(
"monitor.solver_monitoring.momentum must be a mapping when provided.")
7984 unknown_momentum = sorted(set(momentum_cfg.keys()) - set(SOLVER_MONITORING_MOMENTUM_FLAG_MAP.keys()))
7985 if unknown_momentum:
7986 raise ValueError(f
"monitor.solver_monitoring.momentum has unsupported key(s): {unknown_momentum}.")
7987 for key, flag
in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.items():
7988 if key
in momentum_cfg:
7989 value = momentum_cfg[key]
7990 if not isinstance(value, bool):
7991 raise ValueError(f
"monitor.solver_monitoring.momentum.{key} must be boolean.")
7994 poisson_cfg = solver_mon_cfg.get(
"poisson", {})
7995 if poisson_cfg
is None:
7997 if not isinstance(poisson_cfg, dict):
7998 raise ValueError(
"monitor.solver_monitoring.poisson must be a mapping when provided.")
7999 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
8001 raise ValueError(f
"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
8002 for key, flag
in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
8003 if key
in poisson_cfg:
8004 value = poisson_cfg[key]
8005 if not isinstance(value, bool):
8006 raise ValueError(f
"monitor.solver_monitoring.poisson.{key} must be boolean.")
8009 passthrough = solver_mon_cfg.get(
"petsc_passthrough_options", {})
8010 if passthrough
is None:
8012 if not isinstance(passthrough, dict):
8013 raise ValueError(
"monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
8014 flags.update(passthrough)
8018 for key, value
in solver_mon_cfg.items()
8019 if isinstance(key, str)
and key.startswith(
"-")
8021 flags.update(legacy_raw)
8023 unknown_top = sorted(
8025 for key
in solver_mon_cfg.keys()
8026 if key
not in {
"momentum",
"poisson",
"petsc_passthrough_options"}
and not (isinstance(key, str)
and key.startswith(
"-"))
8030 "monitor.solver_monitoring has unsupported key(s): "
8031 f
"{unknown_top}. Use 'momentum'/'poisson' for structured monitors or "
8032 "'petsc_passthrough_options' for raw PETSc flags."
8040 @brief Return the effective particle-console snapshot cadence from monitor.yml.
8041 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
8042 @return Value returned by `resolve_particle_console_output_frequency()`.
8044 if 'particle_console_output_frequency' in io_cfg:
8045 return io_cfg[
'particle_console_output_frequency']
8046 return io_cfg.get(
'data_output_frequency')
8050 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
8051 @param[in] case_cfg The parsed case.yml configuration dictionary.
8052 @param[out] control_lines A list of strings to which C-flags will be appended.
8054 models = case_cfg.get(
'models', {})
8056 'domain': {
'blocks':
'-nblk'},
8057 'physics.fsi': {
'immersed':
'-imm',
'moving_fsi':
'-fsi'},
8058 'physics.particles': {
'count':
'-numParticles'},
8059 'statistics': {
'time_averaging':
'-averaging'}
8061 for section_path, flags
in FLAG_MAP.items():
8062 current_level = models
8064 for key
in section_path.split(
'.'): current_level = current_level[key]
8065 for yaml_key, flag
in flags.items():
8066 if yaml_key
in current_level:
8067 control_lines.append(f
"{flag} {format_flag_value(current_level[yaml_key])}")
8068 except KeyError:
continue
8072 if models.get(
'physics', {}).get(
'dimensionality') ==
'2D':
8073 control_lines.append(
"-TwoD 1")
8075 particles_cfg = models.get(
'physics', {}).get(
'particles', {})
8076 p_init_mode_str = particles_cfg.get(
'init_mode',
'Surface')
8078 control_lines.append(f
"-pinit {pinit_code}")
8079 print(f
" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
8082 point_cfg = particles_cfg.get(
'point_source', {})
8083 if not isinstance(point_cfg, dict):
8084 raise ValueError(
"models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
8086 psrc_x = float(point_cfg[
'x'])
8087 psrc_y = float(point_cfg[
'y'])
8088 psrc_z = float(point_cfg[
'z'])
8089 except (KeyError, TypeError, ValueError):
8090 raise ValueError(
"PointSource init_mode requires numeric point_source.{x,y,z} values.")
8091 control_lines.append(f
"-psrc_x {psrc_x}")
8092 control_lines.append(f
"-psrc_y {psrc_y}")
8093 control_lines.append(f
"-psrc_z {psrc_z}")
8094 print(f
" - Particle Point Source: ({psrc_x}, {psrc_y}, {psrc_z})")
8096 p_restart_mode = particles_cfg.get(
'restart_mode')
8098 p_restart_mode_normalized = str(p_restart_mode).lower()
8099 if p_restart_mode_normalized
not in {
"init",
"load"}:
8100 raise ValueError(f
"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
8101 control_lines.append(f
"-particle_restart_mode \"{p_restart_mode}\"")
8105 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
8106 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
8107 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
8110 if 'operation_mode' in solver_cfg
and isinstance(solver_cfg[
'operation_mode'], dict):
8111 op_mode = solver_cfg[
'operation_mode']
8112 if 'eulerian_field_source' in op_mode:
8114 flags[
'-euler_field_source'] = f
"\"{normalized_source}\""
8115 if 'analytical_type' in op_mode
and op_mode.get(
'analytical_type')
is not None:
8117 flags[
'-analytical_type'] = f
"\"{normalized_analytical_type}\""
8118 if normalized_analytical_type ==
"UNIFORM_FLOW":
8119 uniform_flow_cfg = op_mode.get(
'uniform_flow', {})
8120 if not isinstance(uniform_flow_cfg, dict):
8121 raise ValueError(
"operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
8123 flags[
'-analytical_uniform_u'] = float(uniform_flow_cfg[
'u'])
8124 flags[
'-analytical_uniform_v'] = float(uniform_flow_cfg[
'v'])
8125 flags[
'-analytical_uniform_w'] = float(uniform_flow_cfg[
'w'])
8126 except KeyError
as exc:
8127 raise ValueError(f
"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.")
from exc
8128 except (TypeError, ValueError)
as exc:
8129 raise ValueError(
"operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.")
from exc
8131 verification_cfg = solver_cfg.get(
'verification', {})
8132 if verification_cfg:
8133 if not isinstance(verification_cfg, dict):
8134 raise ValueError(
"verification must be a mapping when provided.")
8135 sources_cfg = verification_cfg.get(
'sources', {})
8136 if not isinstance(sources_cfg, dict):
8137 raise ValueError(
"verification.sources must be a mapping when provided.")
8138 diff_cfg = sources_cfg.get(
'diffusivity')
8139 if diff_cfg
is not None:
8140 if not isinstance(diff_cfg, dict):
8141 raise ValueError(
"verification.sources.diffusivity must be a mapping.")
8143 flags[
'-verification_diffusivity_mode'] = f
"\"{str(diff_cfg['mode']).strip().lower()}\""
8144 flags[
'-verification_diffusivity_profile'] = f
"\"{str(diff_cfg['profile']).strip().upper()}\""
8145 flags[
'-verification_diffusivity_gamma0'] = float(diff_cfg[
'gamma0'])
8146 flags[
'-verification_diffusivity_slope_x'] = float(diff_cfg[
'slope_x'])
8147 except KeyError
as exc:
8148 raise ValueError(f
"verification.sources.diffusivity.{exc.args[0]} is required.")
from exc
8149 except (TypeError, ValueError)
as exc:
8150 raise ValueError(
"verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.")
from exc
8152 scalar_cfg = sources_cfg.get(
'scalar')
8153 if scalar_cfg
is not None:
8154 if not isinstance(scalar_cfg, dict):
8155 raise ValueError(
"verification.sources.scalar must be a mapping.")
8157 flags[
'-verification_scalar_mode'] = f
"\"{str(scalar_cfg['mode']).strip().lower()}\""
8158 flags[
'-verification_scalar_profile'] = f
"\"{str(scalar_cfg['profile']).strip().upper()}\""
8159 except KeyError
as exc:
8160 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
8162 scalar_numeric_keys = {
8163 'CONSTANT': (
'value',),
8164 'LINEAR_X': (
'phi0',
'slope_x'),
8165 'SIN_PRODUCT': (
'amplitude',
'kx',
'ky',
'kz'),
8167 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
8168 for key
in scalar_numeric_keys.get(profile, ()):
8170 flags[f
'-verification_scalar_{key}'] = float(scalar_cfg[key])
8171 except KeyError
as exc:
8172 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
8173 except (TypeError, ValueError)
as exc:
8174 raise ValueError(f
"verification.sources.scalar.{key} must be numeric.")
from exc
8176 transport_cfg = solver_cfg.get(
'scalar_transport', {})
8178 if not isinstance(transport_cfg, dict):
8179 raise ValueError(
"scalar_transport must be a mapping when provided.")
8181 'schmidt_number':
'-schmidt_number',
8182 'turbulent_schmidt_number':
'-turb_schmidt_number',
8184 unknown_transport_keys = sorted(set(transport_cfg.keys()) - set(transport_map.keys()))
8185 if unknown_transport_keys:
8187 f
"scalar_transport has unsupported key(s): {unknown_transport_keys}. "
8188 "Use 'schmidt_number' or 'turbulent_schmidt_number'."
8190 for key, flag
in transport_map.items():
8191 if key
in transport_cfg:
8193 value = float(transport_cfg[key])
8194 except (TypeError, ValueError)
as exc:
8195 raise ValueError(f
"scalar_transport.{key} must be numeric.")
from exc
8197 raise ValueError(f
"scalar_transport.{key} must be positive.")
8200 selected_solver =
None
8201 if 'strategy' in solver_cfg:
8202 s = solver_cfg[
'strategy']
8203 if 'central_diff' in s:
8206 if 'momentum_solver' in s:
8208 elif 'implicit' in s:
8209 raise ValueError(
"Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
8211 ms = solver_cfg.get(
'momentum_solver', {})
8212 if selected_solver
is None:
8213 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
8214 flags[
'-mom_solver_type'] = f
"\"{selected_solver}\""
8216 if 'tolerances' in solver_cfg:
8217 t = solver_cfg[
'tolerances']
8219 'max_iterations':
'-mom_max_pseudo_steps',
8220 'absolute_tol':
'-mom_atol',
8221 'relative_tol':
'-mom_rtol',
8222 'residual_absolute_tol':
'-mom_resid_atol',
8223 'residual_relative_tol':
'-mom_resid_rtol',
8224 'step_tol':
'-imp_stol'
8226 for key, flag
in tol_map.items():
8228 flags[flag] = t[key]
8230 def _append_dualtime_options(cfg: dict):
8232 @brief Append dualtime options.
8233 @param[in] cfg Argument passed to `_append_dualtime_options()`.
8235 if 'max_pseudo_steps' in cfg:
8236 flags[
'-mom_max_pseudo_steps'] = cfg[
'max_pseudo_steps']
8237 if 'absolute_tol' in cfg:
8238 flags[
'-mom_atol'] = cfg[
'absolute_tol']
8239 if 'relative_tol' in cfg:
8240 flags[
'-mom_rtol'] = cfg[
'relative_tol']
8241 if 'step_tol' in cfg:
8242 flags[
'-imp_stol'] = cfg[
'step_tol']
8243 if 'pseudo_cfl' in cfg:
8244 pcfl = cfg[
'pseudo_cfl']
8245 if 'initial' in pcfl:
8246 flags[
'-pseudo_cfl'] = pcfl[
'initial']
8247 if 'minimum' in pcfl:
8248 flags[
'-min_pseudo_cfl'] = pcfl[
'minimum']
8249 if 'maximum' in pcfl:
8250 flags[
'-max_pseudo_cfl'] = pcfl[
'maximum']
8251 if 'growth_factor' in pcfl:
8252 flags[
'-pseudo_cfl_growth_factor'] = pcfl[
'growth_factor']
8253 if 'reduction_factor' in pcfl:
8254 flags[
'-pseudo_cfl_reduction_factor'] = pcfl[
'reduction_factor']
8255 if 'jameson_residual_noise_allowance_factor' in cfg:
8256 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'jameson_residual_noise_allowance_factor']
8257 elif 'rk4_residual_noise_allowance_factor' in cfg:
8258 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'rk4_residual_noise_allowance_factor']
8259 if 'ratio_ema_alpha' in cfg:
8260 flags[
'-mom_ratio_ema_alpha'] = cfg[
'ratio_ema_alpha']
8262 def _append_newton_krylov_options(cfg: dict):
8264 @brief Append validated structured Newton--Krylov PETSc options.
8265 @param[in] cfg Structured Newton--Krylov mapping.
8268 nonlinear = cfg[
"nonlinear_solver"]
8270 "method":
"-mom_nk_snes_type",
8271 "absolute_tolerance":
"-mom_nk_snes_atol",
8272 "relative_tolerance":
"-mom_nk_snes_rtol",
8273 "step_tolerance":
"-mom_nk_snes_stol",
8274 "max_iterations":
"-mom_nk_snes_max_it",
8276 for key, flag
in nonlinear_map.items():
8277 if key
in nonlinear:
8278 flags[flag] = nonlinear[key]
8279 line_search = nonlinear.get(
"line_search", {})
8280 if "type" in line_search:
8281 flags[
"-mom_nk_snes_linesearch_type"] = line_search[
"type"]
8283 linear = cfg[
"linear_solver"]
8285 "method":
"-mom_nk_ksp_type",
8286 "absolute_tolerance":
"-mom_nk_ksp_atol",
8287 "relative_tolerance":
"-mom_nk_ksp_rtol",
8288 "max_iterations":
"-mom_nk_ksp_max_it",
8290 for key, flag
in linear_map.items():
8292 flags[flag] = linear[key]
8293 gmres = linear.get(
"gmres", {})
8294 if "restart" in gmres:
8295 flags[
"-mom_nk_ksp_gmres_restart"] = gmres[
"restart"]
8296 preconditioner = linear.get(
"preconditioner", {})
8297 if "type" in preconditioner:
8298 flags[
"-mom_nk_pc_type"] = preconditioner[
"type"]
8300 if isinstance(ms, dict):
8301 allowed_ms_keys = {
'type',
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4',
'newton_krylov'}
8302 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
8305 f
"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
8306 "Currently supported blocks: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
8309 if 'dual_time_picard_jameson_rk' in ms
and 'dual_time_picard_rk4' in ms:
8311 "Use only momentum_solver.dual_time_picard_jameson_rk; "
8312 "do not also set its deprecated dual_time_picard_rk4 alias."
8314 dt_picard_cfg = ms.get(
'dual_time_picard_jameson_rk', ms.get(
'dual_time_picard_rk4'))
8315 if dt_picard_cfg
is not None:
8316 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK":
8318 f
"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
8320 if not isinstance(dt_picard_cfg, dict):
8321 raise ValueError(
"momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
8322 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
8323 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
8325 "Use only jameson_residual_noise_allowance_factor; "
8326 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
8328 _append_dualtime_options(dt_picard_cfg)
8329 newton_cfg = ms.get(
'newton_krylov')
8330 if newton_cfg
is not None:
8331 if selected_solver !=
"newton_krylov":
8333 f
"momentum_solver.newton_krylov is set but selected solver is {selected_solver}."
8335 _append_newton_krylov_options(newton_cfg)
8336 solution_convergence_cfg = solver_cfg.get(
'solution_convergence', {})
8337 if solution_convergence_cfg
is not None:
8338 if not isinstance(solution_convergence_cfg, dict):
8339 raise ValueError(
"solution_convergence must be a mapping when provided.")
8340 if solution_convergence_cfg
and solution_convergence_cfg.get(
'enabled',
True):
8342 flags[
'-solution_convergence_mode'] = f
"\"{mode}\""
8343 if mode ==
"PERIODIC_DETERMINISTIC":
8344 periodic_cfg = solution_convergence_cfg.get(
'periodic_deterministic')
8345 if not isinstance(periodic_cfg, dict):
8346 raise ValueError(
"solution_convergence.periodic_deterministic must be a mapping when mode is 'periodic_deterministic'.")
8347 flags[
'-solution_convergence_period_steps'] = periodic_cfg[
'period_steps']
8348 if mode ==
"STATISTICAL_STEADY":
8349 statistical_cfg = solution_convergence_cfg.get(
'statistical_steady')
8350 if not isinstance(statistical_cfg, dict):
8351 raise ValueError(
"solution_convergence.statistical_steady must be a mapping when mode is 'statistical_steady'.")
8352 flags[
'-solution_convergence_window_steps'] = statistical_cfg[
'window_steps']
8353 def _normalize_poisson_method(value) -> str:
8355 @brief Normalize a user-facing Poisson linear-solver method name.
8356 @param[in] value Method value from the solver YAML.
8357 @return Lowercase PETSc KSP method token.
8359 method = str(value).strip().lower()
8361 raise ValueError(
"poisson_solver.method cannot be empty.")
8364 def _normalize_poisson_preconditioner(value) -> str:
8366 @brief Normalize and validate the outer Poisson preconditioner name.
8367 @param[in] value Preconditioner value from the solver YAML.
8368 @return PETSc PC token for the supported outer preconditioner.
8370 pc = str(value).strip().lower()
8371 aliases = {
"mg":
"multigrid",
"pcmg":
"multigrid"}
8372 pc = aliases.get(pc, pc)
8373 if pc !=
"multigrid":
8375 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
8376 "The runtime Poisson solver still assumes PETSc PCMG setup."
8380 def _poisson_level_number(level_name) -> str:
8382 @brief Extract the numeric suffix from a `level_N` multigrid level key.
8383 @param[in] level_name YAML level key supplied by the user.
8384 @return Numeric level suffix as a string.
8386 text = str(level_name).strip()
8387 match = re.fullmatch(
r"level_(\d+)", text)
8389 raise ValueError(f
"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
8390 return match.group(1)
8392 def _append_poisson_solver_flags(ps: dict, source_key: str):
8394 @brief Append structured Poisson solver options to the flat PETSc flag map.
8395 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
8396 @param[in] source_key Name of the source YAML block, used in error messages.
8398 if not isinstance(ps, dict):
8399 raise ValueError(f
"{source_key} must be a mapping when provided.")
8403 method = _normalize_poisson_method(ps[
'method'])
8404 flags[
'-ps_ksp_type'] = method
8405 if 'absolute_tolerance' in ps:
8406 flags[
'-ps_ksp_atol'] = ps[
'absolute_tolerance']
8407 flags[
'-poisson_tol'] = ps[
'absolute_tolerance']
8408 if 'relative_tolerance' in ps:
8409 flags[
'-ps_ksp_rtol'] = ps[
'relative_tolerance']
8410 if 'max_iterations' in ps:
8411 flags[
'-ps_ksp_max_it'] = ps[
'max_iterations']
8412 if 'tolerance' in ps:
8413 flags[
'-poisson_tol'] = ps[
'tolerance']
8415 gmres_cfg = ps.get(
'gmres', {})
8416 if gmres_cfg
is not None:
8417 if not isinstance(gmres_cfg, dict):
8418 raise ValueError(f
"{source_key}.gmres must be a mapping when provided.")
8419 if 'restart' in gmres_cfg:
8421 method = _normalize_poisson_method(ps.get(
'method',
'fgmres'))
8422 flags.setdefault(
'-ps_ksp_type', method)
8423 if method
not in {
"gmres",
"fgmres",
"lgmres"}:
8425 f
"{source_key}.gmres.restart is valid only when {source_key}.method "
8426 "is one of 'gmres', 'fgmres', or 'lgmres'."
8428 flags[
'-ps_ksp_gmres_restart'] = gmres_cfg[
'restart']
8430 preconditioner_cfg = ps.get(
'preconditioner', {})
8431 if preconditioner_cfg:
8432 if not isinstance(preconditioner_cfg, dict):
8433 raise ValueError(f
"{source_key}.preconditioner must be a mapping when provided.")
8434 if 'type' in preconditioner_cfg:
8435 flags[
'-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg[
'type'])
8437 if 'multigrid' in ps:
8438 mg = ps[
'multigrid']
8439 if not isinstance(mg, dict):
8440 raise ValueError(f
"{source_key}.multigrid must be a mapping when provided.")
8441 mg_map = {
'levels':
'-mg_level',
'pre_sweeps':
'-mg_pre_it',
'post_sweeps':
'-mg_post_it'}
8442 for key, flag
in mg_map.items():
8443 if key
in mg: flags[flag] = mg[key]
8445 cycle = str(mg[
'cycle']).strip().lower()
8446 if cycle
not in {
"v"}:
8447 raise ValueError(f
"{source_key}.multigrid.cycle currently supports only 'v'.")
8449 mode = str(mg[
'mode']).strip().lower()
8450 if mode
not in {
"multiplicative"}:
8451 raise ValueError(f
"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
8452 if 'semi_coarsening' in mg:
8453 sc = mg[
'semi_coarsening']
8454 if not isinstance(sc, dict):
8455 raise ValueError(f
"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
8459 if 'level_solvers' in mg:
8460 level_solvers = mg[
'level_solvers']
8461 if not isinstance(level_solvers, dict):
8462 raise ValueError(f
"{source_key}.multigrid.level_solvers must be a mapping when provided.")
8463 for level_name, settings
in level_solvers.items():
8464 if not isinstance(settings, dict):
8465 raise ValueError(f
"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
8466 level_num = _poisson_level_number(level_name)
8467 for key, value
in settings.items():
8468 mapped_key = {
'method':
'ksp_type',
'preconditioner':
'pc_type'}.get(key, key)
8471 if 'poisson_solver' in solver_cfg
and 'pressure_solver' in solver_cfg:
8472 if solver_cfg[
'poisson_solver'] != solver_cfg[
'pressure_solver']:
8474 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
8475 "Use 'poisson_solver' only, or make the legacy alias identical."
8477 poisson_cfg = solver_cfg.get(
'poisson_solver', solver_cfg.get(
'pressure_solver'))
8478 if poisson_cfg
is not None:
8479 source_key =
'poisson_solver' if 'poisson_solver' in solver_cfg
else 'pressure_solver'
8480 _append_poisson_solver_flags(poisson_cfg, source_key)
8481 interp_cfg = solver_cfg.get(
'interpolation', {})
8482 if isinstance(interp_cfg, dict):
8483 interp_method_str = interp_cfg.get(
'method',
'Trilinear')
8485 interp_method_str =
'Trilinear'
8487 flags[
'-interpolation_method'] = interp_code
8488 print(f
" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
8490 if 'petsc_passthrough_options' in solver_cfg:
8491 passthrough = solver_cfg[
'petsc_passthrough_options']
8492 if passthrough
is None:
8494 if not isinstance(passthrough, dict):
8495 raise ValueError(
"petsc_passthrough_options must be a mapping when provided.")
8497 for key, value
in passthrough.items():
8500 if '-ps_ksp_type' in flags:
8501 summary_bits.append(f
"method={flags['-ps_ksp_type']}")
8502 if '-ps_ksp_atol' in flags:
8503 summary_bits.append(f
"atol={flags['-ps_ksp_atol']}")
8504 if '-ps_ksp_rtol' in flags:
8505 summary_bits.append(f
"rtol={flags['-ps_ksp_rtol']}")
8506 if '-ps_ksp_max_it' in flags:
8507 summary_bits.append(f
"max_it={flags['-ps_ksp_max_it']}")
8508 if '-mg_level' in flags:
8509 summary_bits.append(f
"mg_levels={flags['-mg_level']}")
8511 print(f
" - Poisson Solver: {', '.join(summary_bits)}")
8516 @brief Generates the main .control file for the C-solver.
8517 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
8518 into a single, machine-readable file of command-line flags.
8519 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
8520 @param[in] run_id Argument passed to `generate_solver_control_file()`.
8521 @param[in] configs Argument passed to `generate_solver_control_file()`.
8522 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
8523 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
8524 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
8525 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
8526 @return Value returned by `generate_solver_control_file()`.
8528 print(
"[INFO] Generating master solver control file...")
8529 case_cfg, solver_cfg, monitor_cfg = configs[
'case'], configs[
'solver'], configs[
'monitor']
8530 source_files = {
'Case': configs[
'case_path'],
'Solver': configs[
'solver_path'],
'Monitor': configs[
'monitor_path']}
8534 props, run_ctrl = case_cfg[
'properties'], case_cfg[
'run_control']
8535 scales, fluid, ic = props[
'scaling'], props[
'fluid'], props[
'initial_conditions']
8537 L_ref, U_ref, rho, mu = float(scales[
'length_ref']), float(scales[
'velocity_ref']), float(fluid[
'density']), float(fluid[
'viscosity'])
8538 reynolds = (rho * U_ref * L_ref) / mu
if mu != 0
else float(
'inf')
8539 dt_phys = float(run_ctrl[
'dt_physical'])
8540 T_ref = L_ref / U_ref
if U_ref != 0
else float(
'inf')
8541 dt_nondim = dt_phys / T_ref
if T_ref != float(
'inf')
else 0.0
8543 finit_mode_str = resolved_ic[
"label"]
8544 finit_code = resolved_ic[
"finit"]
8545 ic_params = resolved_ic[
"cli_params"]
8546 print(f
" - Reynolds Number (Re) = {reynolds:.4f}")
8547 print(f
" - Non-Dimensional dt* = {dt_nondim:.6f}")
8549 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
8551 start_step = int(run_ctrl.get(
"start_step", 0)
or 0)
8552 ic_is_authoritative = eulerian_source ==
"solve" and start_step == 0
8554 if ic_is_authoritative:
8555 print(f
" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
8556 if "ucont_x" in ic_params:
8558 f
"-ucont_x {ic_params['ucont_x']}",
8559 f
"-ucont_y {ic_params['ucont_y']}",
8560 f
"-ucont_z {ic_params['ucont_z']}",
8562 if "ic_velocity_physical" in ic_params:
8563 ic_cli.append(f
"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
8564 if "flow_direction" in ic_params:
8565 ic_cli.append(f
"-flow_direction {ic_params['flow_direction']}")
8568 f
"[WARN] Ignoring configured initial condition '{finit_mode_str}' because "
8569 f
"eulerian_field_source={eulerian_source!r} and start_step={start_step} select another source.",
8573 control_lines.extend([
8574 f
"-start_step {run_ctrl['start_step']}", f
"-totalsteps {run_ctrl['total_steps']}",
8575 f
"-ren {reynolds}", f
"-dt {dt_nondim}", f
"-finit {finit_code}",
8577 f
"-scaling_L_ref {L_ref}", f
"-scaling_U_ref {U_ref}", f
"-scaling_rho_ref {rho}"
8579 except (KeyError, TypeError, ZeroDivisionError, ValueError)
as e:
8580 print(f
"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
8584 if monitor_files.get(
"whitelist"):
8585 control_lines.append(f
"-whitelist_config_file {monitor_files['whitelist']}")
8586 if monitor_files.get(
"profile"):
8587 control_lines.append(f
"-profile_config_file {monitor_files['profile']}")
8588 profiling_cfg = monitor_files.get(
"profiling", {})
8589 control_lines.append(f
"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
8590 if profiling_cfg.get(
"mode") !=
"off":
8591 control_lines.append(f
"-profiling_timestep_file {profiling_cfg.get('timestep_file', 'Profiling_Timestep_Summary.csv')}")
8592 control_lines.append(f
"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
8594 memory_log_cfg = diagnostics_cfg[
"runtime_memory_log"]
8595 control_lines.append(f
"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
8596 control_lines.append(f
"-runtime_memory_log_file {memory_log_cfg.get('file', 'Runtime_Memory.log')}")
8598 walltime_guard_policy = configs.get(
"walltime_guard_policy")
8599 if walltime_guard_policy
is not None:
8600 control_lines.extend(
8602 f
"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
8603 f
"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
8604 f
"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
8605 f
"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
8606 f
"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
8610 grid_cfg = case_cfg.get(
'grid', {})
8611 grid_mode = grid_cfg.get(
'mode')
8612 expected_nblk = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
8614 if grid_mode ==
'file':
8615 print(
"[INFO] Grid Mode: Using external file...")
8616 case_file_dir = os.path.dirname(configs[
'case_path'])
8617 source_grid = grid_cfg[
'source_file']
8618 if not os.path.isabs(source_grid):
8619 source_grid = os.path.abspath(os.path.join(case_file_dir, source_grid))
8620 grid_for_validation = source_grid
8621 legacy_cfg = grid_cfg.get(
"legacy_conversion")
8622 if isinstance(legacy_cfg, dict):
8623 if legacy_cfg.get(
"enabled",
True):
8624 print(
"[INFO] Grid file legacy conversion enabled; converting with grid.gen...")
8626 configs[
'case_path'],
8631 nondim_grid_path = os.path.join(run_dir,
"config",
"grid.run")
8634 grid_for_validation, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8637 f
"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
8638 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8640 control_lines.append(f
"-grid_file {nondim_grid_path}")
8641 except Exception
as e:
8642 print(f
"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
8644 elif grid_mode ==
'grid_gen':
8645 print(
"[INFO] Grid Mode: Generating external grid via grid.gen...")
8646 nondim_grid_path = os.path.join(run_dir,
"config",
"grid.run")
8647 if continue_mode
and os.path.isfile(nondim_grid_path):
8648 print(f
"[INFO] Continue mode: reusing staged grid: {os.path.relpath(nondim_grid_path)}")
8649 control_lines.append(f
"-grid_file {nondim_grid_path}")
8655 generated_grid, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8658 f
"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
8659 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8661 control_lines.append(f
"-grid_file {nondim_grid_path}")
8662 except Exception
as e:
8663 print(f
"[FATAL] Grid generation failed: {e}", file=sys.stderr)
8665 elif grid_mode ==
'programmatic_c':
8666 print(
"[INFO] Grid Mode: Programmatic C...")
8668 control_lines.append(
"-grid")
8669 for p_key
in GRID_DA_PROCESSOR_KEYS:
8670 grid_settings.pop(p_key,
None)
8671 for key, value
in grid_settings.items(): control_lines.append(f
"-{key} {format_flag_value(value)}")
8672 if resolved_ic[
"kind"] ==
"ic_gen" and ic_is_authoritative:
8673 nondim_grid_path = os.path.join(run_dir,
"config",
"grid.run")
8676 grid_cfg.get(
'programmatic_settings', {}), nondim_grid_path, L_ref
8679 f
"[INFO] Materialized grid.run for ic_gen: {os.path.relpath(nondim_grid_path)} "
8680 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8682 except Exception
as e:
8683 print(f
"[FATAL] Failed to generate grid.run for ic_gen: {e}", file=sys.stderr)
8686 raise ValueError(f
"Unknown or missing grid mode '{grid_mode}' in case.yml.")
8688 if resolved_ic[
"kind"]
in {
"file",
"ic_gen"}:
8689 if ic_is_authoritative:
8692 except Exception
as e:
8693 print(f
"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
8695 control_lines.extend([
8696 f
"-ic_field {resolved_ic['field_code']}",
8697 f
"-ic_dir {staged_ic['directory']}",
8699 print(f
" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
8703 except ValueError
as e:
8704 print(f
"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
8706 control_lines.append(f
"-bcs_files \"{','.join(bcs_files)}\"")
8712 if 'solver_parameters' in case_cfg:
8713 params = case_cfg[
'solver_parameters']
8715 for key, value
in params.items():
8716 control_lines.append(f
"{key} {format_flag_value(value)}")
8720 except ValueError
as e:
8721 print(f
"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
8727 except ValueError
as e:
8728 print(f
"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
8732 io_cfg = monitor_cfg.get(
'io', {})
8734 if 'data_output_frequency' in io_cfg: control_lines.append(f
"-tio {io_cfg['data_output_frequency']}")
8735 if particle_console_output_freq
is not None:
8736 control_lines.append(f
"-particle_console_output_freq {particle_console_output_freq}")
8737 if 'particle_log_interval' in io_cfg: control_lines.append(f
"-logfreq {io_cfg['particle_log_interval']}")
8738 if 'directories' in io_cfg:
8739 dirs = io_cfg[
'directories']
8740 if 'output' in dirs: control_lines.append(f
"-output_dir {dirs['output']}")
8741 if 'restart' in dirs
and not restart_source_dir:
8742 control_lines.append(f
"-restart_dir {dirs['restart']}")
8743 if 'log' in dirs: control_lines.append(f
"-log_dir {dirs['log']}")
8744 if 'eulerian_subdir' in dirs: control_lines.append(f
"-euler_subdir {dirs['eulerian_subdir']}")
8745 if 'particle_subdir' in dirs: control_lines.append(f
"-particle_subdir {dirs['particle_subdir']}")
8746 if restart_source_dir:
8747 control_lines.append(f
"-restart_dir {restart_source_dir}")
8749 control_lines.append(
"-continue_mode true")
8751 final_content =
generate_header(run_id, source_files) +
"\n".join(control_lines)
8752 control_file_path = os.path.join(run_dir,
"config", f
"{run_id}.control")
8753 with open(control_file_path,
"w")
as f: f.write(final_content)
8754 print(f
"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
8755 return os.path.abspath(control_file_path)
8759 @brief Generates a key=value config file (post.run) for the C post-processor.
8760 @details Translates the structured post-processing YAML into the specific flat
8761 key-value format required by the C executable, including complex,
8762 semicolon-separated pipeline strings.
8763 @param[in] run_dir The path to the main run directory.
8764 @param[in] run_id The unique identifier for the run.
8765 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
8766 @param[in] source_files A dictionary of source files for the header.
8767 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
8768 @return The absolute path to the generated post.run recipe file.
8770 print(
"[INFO] Generating post-processor recipe file (post.run)...")
8771 config_dir = os.path.join(run_dir,
"config")
8772 post_recipe_path = os.path.join(config_dir,
"post.run")
8777 for key, value
in c_config.items():
8778 if value
is not None and str(value) !=
"":
8779 lines.append(f
"{key} = {value}")
8781 with open(post_recipe_path,
"w")
as f:
8782 f.write(
"\n".join(lines))
8783 print(f
"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
8784 return os.path.abspath(post_recipe_path)
8786def execute_command(command: list, run_dir: str, log_filename: str, monitor_cfg: dict =
None):
8788 @brief Executes a command, streaming its output to the console and a log file.
8790 If None, the process inherits the parent's environment directly.
8791 @param[in] command Argument passed to `execute_command()`.
8792 @param[in] run_dir Argument passed to `execute_command()`.
8793 @param[in] log_filename Argument passed to `execute_command()`.
8794 @param[in] monitor_cfg Argument passed to `execute_command()`.
8797 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
8799 print(f
"[INFO] Launching Command...\n > {format_command_for_display(command)}")
8800 print(f
" Log file: {os.path.relpath(log_path)}")
8805 "stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
8806 "cwd": run_dir,
"bufsize": 1,
"universal_newlines":
True,
8807 "encoding":
'utf-8',
"errors":
'replace'
8811 print(
"[INFO] Creating custom environment to set LOG_LEVEL.")
8812 run_env = os.environ.copy()
8813 verbosity = monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()
8814 run_env[
'LOG_LEVEL'] = verbosity
8815 print(f
"[INFO] Setting LOG_LEVEL={verbosity} for C executable.")
8816 popen_kwargs[
'env'] = run_env
8818 print(
"[INFO] Using inherited environment for process.")
8823 process = subprocess.Popen(command, **popen_kwargs)
8825 with open(log_path,
"w")
as log_file:
8826 for line
in process.stdout:
8827 sys.stdout.write(line)
8828 log_file.write(line)
8830 return_code = process.returncode
8832 if return_code == 0:
8833 print(f
"[SUCCESS] Execution finished successfully.")
8835 print(f
"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
8836 sys.exit(return_code)
8837 except FileNotFoundError:
8838 print(f
"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
8839 print(
" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
8841 except Exception
as e:
8842 print(f
"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
8848 @brief Render a shell-safe command string for console and log output.
8849 @param[in] command Argument passed to `format_command_for_display()`.
8850 @return Value returned by `format_command_for_display()`.
8852 return " ".join(shlex.quote(str(part))
for part
in command)
8857 @brief Resolve a command log filename relative to the run directory.
8858 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
8859 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
8860 @return Value returned by `resolve_command_log_path()`.
8862 if os.path.dirname(log_filename):
8863 return os.path.join(run_dir, log_filename)
8864 return os.path.join(run_dir,
"logs", log_filename)
8869 @brief Raised when an external command exits unsuccessfully.
8872 def __init__(self, command: list, returncode: int, details: str =
None):
8874 @brief Initialize a command execution error.
8875 @param[in] command Argument passed to `__init__()`.
8876 @param[in] returncode Argument passed to `__init__()`.
8877 @param[in] details Argument passed to `__init__()`.
8882 detail_suffix = f
": {details}" if details
else ""
8884 f
"Command failed with exit code {returncode}: {format_command_for_display(command)}{detail_suffix}"
8890 @brief Raised when plot.gen reports a missing optional dependency.
8896 @brief Run a command and capture combined stdout/stderr details for later inspection.
8897 @param[in] command Argument passed to `_run_captured_command()`.
8898 @param[in] run_dir Argument passed to `_run_captured_command()`.
8899 @return Value returned by `_run_captured_command()`.
8902 return subprocess.run(
8906 capture_output=
True,
8911 except FileNotFoundError
as exc:
8912 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
8917 @brief Raise `CommandExecutionError` when a captured command failed.
8918 @param[in] command Argument passed to `_require_successful_command()`.
8919 @param[in] result Argument passed to `_require_successful_command()`.
8921 if result.returncode == 0:
8923 details = (result.stderr
or result.stdout).strip()
8929 @brief Run a command, require success, and return stripped stdout text.
8930 @param[in] command Argument passed to `_capture_command_stdout()`.
8931 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
8932 @return Value returned by `_capture_command_stdout()`.
8936 return result.stdout.strip()
8941 @brief Stream command output to stdout and an already-open log file.
8942 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
8943 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
8944 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
8947 print(f
"[INFO] Running: {display}")
8948 log_file.write(f
"$ {display}\n")
8952 "stdout": subprocess.PIPE,
8953 "stderr": subprocess.STDOUT,
8956 "universal_newlines":
True,
8957 "encoding":
"utf-8",
8958 "errors":
"replace",
8962 process = subprocess.Popen(command, **popen_kwargs)
8963 except FileNotFoundError
as exc:
8964 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
8967 for line
in process.stdout:
8968 sys.stdout.write(line)
8969 log_file.write(line)
8970 return_code = process.wait()
8971 log_file.write(
"\n")
8973 if return_code != 0:
8979 @brief Capture the current git HEAD branch name and commit hash.
8980 @param[in] run_dir Argument passed to `_get_git_head_state()`.
8981 @return Value returned by `_get_git_head_state()`.
8984 branch_result =
_run_captured_command([
"git",
"symbolic-ref",
"--quiet",
"--short",
"HEAD"], run_dir)
8985 branch_name = branch_result.stdout.strip()
if branch_result.returncode == 0
else None
8986 return {
"branch": branch_name,
"commit": head_commit}
8991 @brief Return local branch names plus their configured upstreams.
8992 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
8993 @return Value returned by `_get_local_branches_with_upstreams()`.
8996 [
"git",
"for-each-ref",
"--sort=refname",
"--format=%(refname:short)\t%(upstream:short)",
"refs/heads"],
9000 for line
in output.splitlines():
9001 if not line.strip():
9003 branch_name, _, upstream_name = line.partition(
"\t")
9004 branches.append((branch_name, upstream_name
or None))
9010 @brief Return `True` when the repository has staged or unstaged tracked changes.
9011 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
9012 @return Value returned by `_working_tree_has_tracked_changes()`.
9014 command = [
"git",
"status",
"--porcelain",
"--untracked-files=no"]
9017 return bool(result.stdout.strip())
9022 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
9023 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
9024 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
9025 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
9027 cleanup_command = [
"git",
"rebase",
"--abort"]
if rebase
else [
"git",
"merge",
"--abort"]
9029 if result.returncode == 0:
9030 print(f
"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
9031 log_file.write(f
"$ {format_command_for_display(cleanup_command)}\n")
9033 sys.stdout.write(result.stdout)
9034 log_file.write(result.stdout)
9036 sys.stderr.write(result.stderr)
9037 log_file.write(result.stderr)
9038 log_file.write(
"\n")
9042 details = (result.stderr
or result.stdout).strip()
9045 f
"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
9047 print(message, file=sys.stderr)
9048 log_file.write(message +
"\n")
9054 @brief Restore the repository back to the branch or detached commit it started on.
9055 @param[in] run_dir Argument passed to `_restore_git_head()`.
9056 @param[in] original_head Argument passed to `_restore_git_head()`.
9057 @param[in] log_file Argument passed to `_restore_git_head()`.
9060 if original_head[
"branch"]:
9061 if current_state[
"branch"] == original_head[
"branch"]:
9066 if current_state[
"branch"]
is None and current_state[
"commit"] == original_head[
"commit"]:
9073 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
9074 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
9075 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
9076 @param[in] rebase Argument passed to `pull_all_source_branches()`.
9079 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
9081 print(
"\n" +
"="*23 +
" PULL SOURCE STAGE " +
"="*22)
9082 print(
"[INFO] Refreshing all local source branches that track an upstream.")
9083 print(f
" Log file: {os.path.relpath(log_path)}")
9090 "Multi-branch pull requires a clean tracked working tree in the source repository. "
9091 "Commit or stash those changes first, or rerun with --current-branch-only."
9094 except (CommandExecutionError, RuntimeError)
as exc:
9095 print(f
"[FATAL] {exc}", file=sys.stderr)
9096 sys.exit(getattr(exc,
"returncode", 1))
9099 print(
"[FATAL] No local branches were found in the source repository.", file=sys.stderr)
9102 if original_head[
"branch"]:
9103 branches = [item
for item
in branches
if item[0] != original_head[
"branch"]] + [
9104 item
for item
in branches
if item[0] == original_head[
"branch"]
9107 skipped_branches = []
9108 current_operation =
None
9110 restore_error =
None
9112 with open(log_path,
"w", encoding=
"utf-8")
as log_file:
9113 log_file.write(f
"# PICurv pull-source all-branch sync\n")
9114 log_file.write(f
"# repository: {os.path.abspath(run_dir)}\n")
9115 log_file.write(f
"# started: {datetime.now().isoformat()}\n")
9117 f
"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
9121 for branch_name, upstream_name
in branches:
9122 if not upstream_name:
9123 warning = f
"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
9124 print(warning, file=sys.stderr)
9125 log_file.write(warning +
"\n")
9126 skipped_branches.append(branch_name)
9129 print(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
9130 log_file.write(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
9132 current_operation = f
"checkout:{branch_name}"
9135 pull_command = [
"git",
"pull"]
9137 pull_command.append(
"--rebase")
9138 current_operation = f
"pull:{branch_name}"
9140 current_operation =
None
9141 except CommandExecutionError
as exc:
9143 if current_operation
and current_operation.startswith(
"pull:"):
9148 except CommandExecutionError
as exc:
9155 f
"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
9156 f
"Check log: {os.path.relpath(log_path)}",
9159 sys.exit(restore_error.returncode)
9161 f
"[FATAL] Multi-branch pull failed. Original branch restored. "
9162 f
"Check log: {os.path.relpath(log_path)}",
9165 sys.exit(pull_error.returncode)
9169 f
"[FATAL] Branch updates completed, but the original branch could not be restored. "
9170 f
"Check log: {os.path.relpath(log_path)}",
9173 sys.exit(restore_error.returncode)
9175 if skipped_branches:
9176 print(f
"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
9177 print(
"[SUCCESS] All local tracking branches are up to date.")
9181 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
9182 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
9183 @return Value returned by `auto_identify_run_inputs()`.
9185 all_yml_files = glob.glob(os.path.join(config_dir,
"*.yml"))
9186 case_path, monitor_path =
None,
None
9187 for f_path
in all_yml_files:
9190 if not isinstance(content, dict):
9192 if 'models' in content
and 'boundary_conditions' in content:
9194 elif 'io' in content
and 'logging' in content:
9195 monitor_path = f_path
9196 except Exception
as e:
9197 print(f
"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
9199 solver_control_path = glob.glob(os.path.join(config_dir,
"*.control"))[0]
9201 solver_control_path =
None
9202 return case_path, monitor_path, solver_control_path
9206 @brief Resolve post source directory token and optionally enforce existence.
9207 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
9208 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
9209 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
9210 @param[in] strict Argument passed to `resolve_post_source_directory()`.
9211 @return Value returned by `resolve_post_source_directory()`.
9213 solver_output_dir_rel = monitor_cfg.get(
'io', {}).get(
'directories', {}).get(
'output',
'output')
9214 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
9216 if source_dir_template ==
'<solver_output_dir>':
9217 resolved_source_dir = solver_output_dir_abs
9218 print(f
"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
9220 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
9221 print(f
"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
9223 if strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
9225 f
"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
9229 if not strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
9230 print(
"[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
9231 return resolved_source_dir
9238 case_index_tsv: str,
9246 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
9247 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
9248 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
9249 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
9250 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
9251 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
9252 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
9253 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
9254 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
9255 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
9256 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
9257 @return Value returned by `render_slurm_array_stage_script()`.
9260 resources = effective_cluster_cfg.get(
"resources", {})
9261 notifications = effective_cluster_cfg.get(
"notifications", {})
or {}
9262 execution = effective_cluster_cfg.get(
"execution", {})
or {}
9263 module_setup = execution.get(
"module_setup", [])
or []
9264 extra_sbatch = execution.get(
"extra_sbatch")
9268 f
"#SBATCH --job-name={job_name}",
9269 f
"#SBATCH --nodes={resources['nodes']}",
9270 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
9271 f
"#SBATCH --mem={resources['mem']}",
9272 f
"#SBATCH --time={resources['time']}",
9273 f
"#SBATCH --output={stdout_path}",
9274 f
"#SBATCH --error={stderr_path}",
9275 f
"#SBATCH --account={resources['account']}",
9276 f
"#SBATCH --array={array_spec}",
9278 partition = resources.get(
"partition")
9280 lines.append(f
"#SBATCH --partition={partition}")
9281 mail_user = notifications.get(
"mail_user")
9282 mail_type = notifications.get(
"mail_type")
9284 lines.append(f
"#SBATCH --mail-user={mail_user}")
9286 lines.append(f
"#SBATCH --mail-type={mail_type}")
9287 if isinstance(extra_sbatch, dict):
9288 for key, value
in extra_sbatch.items():
9290 if not flag.startswith(
"--"):
9292 if isinstance(value, bool):
9294 lines.append(f
"#SBATCH {flag}")
9295 elif value
is not None:
9296 lines.append(f
"#SBATCH {flag}={value}")
9297 elif isinstance(extra_sbatch, list):
9298 for token
in extra_sbatch:
9299 lines.append(f
"#SBATCH {token}")
9303 "set -euo pipefail",
9305 f
'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
9306 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
9307 'if [ -z "$LINE" ]; then',
9308 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
9311 "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\"",
9313 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
9316 if stage ==
"solve":
9318 for key, value
in walltime_guard_exports.items():
9319 lines.append(f
"export {key}={value}")
9321 lines.append(
'export LOG_LEVEL="${LOG_LEVEL}"')
9323 for setup_line
in module_setup:
9324 lines.append(str(setup_line))
9326 if stage ==
"solve":
9328 effective_cluster_cfg,
9330 [
"-control_file",
"$CONTROL_FILE"]
9334 effective_cluster_cfg,
9336 [
"-control_file",
"$CONTROL_FILE",
"-postprocessing_config_file",
"$POST_RECIPE_FILE"],
9341 def _token(tok: str) -> str:
9343 @brief Perform token.
9344 @param[in] tok Argument passed to `_token()`.
9345 @return Value returned by `_token()`.
9347 if tok.startswith(
"$"):
9349 return shlex.quote(str(tok))
9351 diag_var =
"${SOLVE_DIAGNOSTIC_ARGS}" if stage ==
"solve" else "${POST_DIAGNOSTIC_ARGS}"
9352 command_text =
" ".join(_token(t)
for t
in cmd)
9353 executable_token = _token(solver_exe
if stage ==
"solve" else post_exe)
9356 if executable_token
and command_text.count(executable_token) == 1:
9357 command_text = command_text.replace(f
"{executable_token} ", f
"{executable_token} {diag_var} ", 1)
9358 lines.append(f
"exec {command_text}")
9360 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
9361 with open(script_path,
"w")
as f:
9362 f.write(
"\n".join(lines) +
"\n")
9363 os.chmod(script_path, 0o755)
9374 @brief Generate a single-node sbatch script that runs metrics aggregation.
9375 @param[in] script_path Path to write the sbatch script.
9376 @param[in] job_name Slurm job name.
9377 @param[in] cluster_cfg Parsed cluster YAML dictionary.
9378 @param[in] study_dir Absolute path to the study directory.
9379 @param[in] picurv_path Absolute path to the picurv script.
9381 resources = cluster_cfg.get(
"resources", {})
9382 notifications = cluster_cfg.get(
"notifications", {})
or {}
9383 execution = cluster_cfg.get(
"execution", {})
or {}
9384 module_setup = execution.get(
"module_setup", [])
or []
9386 scheduler_dir = os.path.join(study_dir,
"scheduler")
9389 f
"#SBATCH --job-name={job_name}",
9390 "#SBATCH --nodes=1",
9391 "#SBATCH --ntasks-per-node=1",
9393 "#SBATCH --time=00:10:00",
9394 f
"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
9395 f
"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
9396 f
"#SBATCH --account={resources['account']}",
9398 partition = resources.get(
"partition")
9400 lines.append(f
"#SBATCH --partition={partition}")
9401 mail_user = notifications.get(
"mail_user")
9402 mail_type = notifications.get(
"mail_type")
9404 lines.append(f
"#SBATCH --mail-user={mail_user}")
9406 lines.append(f
"#SBATCH --mail-type={mail_type}")
9410 "set -euo pipefail",
9411 'echo "[$(date)] Running metrics aggregation"',
9414 for setup_line
in module_setup:
9415 lines.append(str(setup_line))
9418 f
"exec {shlex.quote(picurv_path)} sweep --reaggregate"
9419 f
" --study-dir {shlex.quote(study_dir)}"
9422 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
9423 with open(script_path,
"w")
as f:
9424 f.write(
"\n".join(lines) +
"\n")
9425 os.chmod(script_path, 0o755)
9430 @brief Reduce a metric series to one scalar according to the requested reducer.
9431 @param[in] values Sequence of numeric values.
9432 @param[in] reduction Reduction keyword.
9433 @return Value returned by `reduce_metric_values()`.
9438 reduction = str(reduction).lower()
9439 if reduction ==
"mean":
9440 return float(np.mean(values))
9441 if reduction ==
"min":
9442 return float(np.min(values))
9443 if reduction ==
"max":
9444 return float(np.max(values))
9445 if reduction ==
"p95":
9446 return float(np.percentile(values, 95.0))
9447 return float(values[-1])
9452 @brief Extract a scalar metric from a CSV source.
9453 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
9454 @param[in] spec Argument passed to `extract_metric_from_csv()`.
9455 @return Value returned by `extract_metric_from_csv()`.
9457 file_glob = spec.get(
"file_glob",
"**/*_msd.csv")
9458 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
9461 csv_path = candidates[0]
9463 with open(csv_path,
"r", newline=
"")
as f:
9464 reader = csv.DictReader(f)
9465 if reader.fieldnames:
9470 column = spec.get(
"column")
9471 numerator_column = spec.get(
"numerator_column")
9472 denominator_column = spec.get(
"denominator_column")
9473 denominator_floor = float(spec.get(
"denominator_floor", 0.0)
or 0.0)
9474 if not column
and not numerator_column:
9475 for name
in reversed(reader.fieldnames):
9476 if name
and name.lower()
not in {
"step",
"time",
"timestep"}:
9479 if not column
and not numerator_column:
9484 if numerator_column:
9485 numerator = float(row[numerator_column])
9486 denominator = float(row[denominator_column])
9487 denominator = max(denominator_floor, denominator)
9488 if denominator == 0.0:
9490 values.append(numerator / denominator)
9492 values.append(float(row[column]))
9502 @brief Extract a scalar metric from a log file using regex.
9503 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
9504 @param[in] spec Argument passed to `extract_metric_from_log()`.
9505 @return Value returned by `extract_metric_from_log()`.
9507 file_glob = spec.get(
"file_glob",
"logs/*.log")
9508 regex = spec.get(
"regex")
9511 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
9514 pattern = re.compile(regex)
9516 for path
in candidates:
9518 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
9520 m = pattern.search(line)
9523 values.append(float(m.group(1)))
9533 @brief Normalize study metric definitions to a common dictionary form.
9534 @param[in] metric Argument passed to `normalize_metric_spec()`.
9535 @return Value returned by `normalize_metric_spec()`.
9537 if isinstance(metric, str):
9538 if metric.lower()
in {
"msd",
"msd_final"}:
9540 "name":
"msd_final",
9541 "source":
"statistics_csv",
9542 "file_glob":
"**/*_msd.csv",
9543 "reduction":
"last",
9545 return {
"name": metric,
"source":
"log_regex",
"regex": metric}
9550 @brief Collect metric values from generated case directories into one CSV.
9551 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
9552 @param[in] cases Argument passed to `aggregate_study_metrics()`.
9553 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
9554 @return Value returned by `aggregate_study_metrics()`.
9556 metrics = study_cfg.get(
"metrics", [])
9558 metrics = [
"msd_final"]
9563 row = {
"case_id": case[
"case_id"]}
9564 for p_key, p_val
in case[
"parameters"].items():
9566 for spec
in normalized_specs:
9567 name = spec.get(
"name",
"metric")
9568 source = str(spec.get(
"source",
"")).lower()
9569 if source
in {
"statistics_csv",
"csv"}:
9571 elif source
in {
"log_regex",
"log"}:
9576 normalize_key = spec.get(
"normalize_by_parameter")
9577 if value
is not None and normalize_key:
9578 denom = case.get(
"parameters", {}).get(normalize_key)
9580 denom = float(denom)
9583 if denom
not in (
None, 0.0):
9584 value = float(value) / denom
9597 for k
in row.keys():
9602 os.makedirs(results_dir, exist_ok=
True)
9603 out_csv = os.path.join(results_dir,
"metrics_table.csv")
9604 with open(out_csv,
"w", newline=
"")
as f:
9605 writer = csv.DictWriter(f, fieldnames=all_keys)
9606 writer.writeheader()
9607 writer.writerows(rows)
9608 print(f
"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
9613 @brief Infer x-axis key/values for study plots.
9614 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
9615 @param[in] rows Argument passed to `infer_plot_x_axis()`.
9616 @return Value returned by `infer_plot_x_axis()`.
9619 if not params
or not rows:
9622 study_type = study_cfg.get(
"study_type")
9623 if study_type ==
"grid_independence":
9624 has_im =
"case.grid.programmatic_settings.im" in params
9625 has_jm =
"case.grid.programmatic_settings.jm" in params
9626 has_km =
"case.grid.programmatic_settings.km" in params
9627 if has_im
and has_jm
and has_km:
9631 im = float(row[
"case.grid.programmatic_settings.im"])
9632 jm = float(row[
"case.grid.programmatic_settings.jm"])
9633 km = float(row[
"case.grid.programmatic_settings.km"])
9634 xs.append((im * jm * km) ** (1.0 / 3.0))
9637 return "N^(1/3)", xs
9643 xs.append(float(row[primary]))
9650 @brief Generate metric-vs-parameter plots for completed studies.
9651 @param[in] study_cfg Argument passed to `generate_study_plots()`.
9652 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
9653 @param[in] plots_dir Argument passed to `generate_study_plots()`.
9654 @return Value returned by `generate_study_plots()`.
9656 plotting_cfg = study_cfg.get(
"plotting", {})
or {}
9657 if plotting_cfg.get(
"enabled",
True)
is False:
9658 print(
"[INFO] Plotting disabled by study.yml.")
9662 print(
"[WARNING] matplotlib not available; skipping plot generation.")
9664 if not metrics_csv
or not os.path.isfile(metrics_csv):
9667 with open(metrics_csv,
"r", newline=
"")
as f:
9668 reader = csv.DictReader(f)
9674 if not x_name
or x_values
is None:
9675 print(
"[WARNING] Could not infer numeric x-axis for plots; skipping.")
9680 for key
in rows[0].keys():
9681 if key
in {
"case_id"}:
9683 if key
in param_keys:
9685 metric_keys.append(key)
9687 out_format = plotting_cfg.get(
"output_format",
"png")
9688 os.makedirs(plots_dir, exist_ok=
True)
9690 for metric
in metric_keys:
9695 y_values.append(float(row[metric]))
9701 plt.figure(figsize=(7.0, 4.2))
9702 plt.plot(x_values, y_values, marker=
"o", linewidth=1.5)
9705 plt.title(f
"{metric} vs {x_name}")
9706 plt.grid(
True, alpha=0.3)
9707 out_path = os.path.join(plots_dir, f
"{metric}_vs_{x_name.replace('/', '_')}.{out_format}")
9709 plt.savefig(out_path, dpi=150)
9711 generated.append(out_path)
9713 print(f
"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
9719 @brief Render a command list as a shell-safe display string.
9720 @param[in] command_tokens Argument passed to `_command_to_string()`.
9721 @return Value returned by `_command_to_string()`.
9723 return " ".join(shlex.quote(str(tok))
for tok
in command_tokens)
9728 @brief Resolve post source directory without side effects or stdout/stderr output.
9729 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
9730 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9731 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9732 @return Value returned by `_resolve_post_source_directory_preview()`.
9734 solver_output_dir_rel = monitor_cfg.get(
'io', {}).get(
'directories', {}).get(
'output',
'output')
9735 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
9737 if source_dir_template ==
'<solver_output_dir>':
9738 return solver_output_dir_abs
9739 return os.path.abspath(os.path.join(run_dir, source_dir_template))
9744 @brief Build a no-write execution plan for `run --dry-run`.
9745 @param[in] args Command-line style argument list supplied to the function.
9746 @return Value returned by `build_run_dry_plan()`.
9750 "created_at": datetime.now().isoformat(),
9757 if args.dry_run
and args.no_submit:
9758 plan[
"warnings"].append(
"--dry-run takes precedence over --no-submit; no files will be written.")
9760 cluster_mode = bool(getattr(args,
"cluster",
None))
9763 solver_num_procs_effective = args.num_procs
9764 post_num_procs_effective = 1
9767 solver_control_path =
None
9768 loaded_case_cfg =
None
9769 loaded_monitor_cfg =
None
9770 resolved_restart_source_dir =
None
9773 cluster_path = os.path.abspath(args.cluster)
9776 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
9777 if args.scheduler
and args.scheduler.lower() != scheduler_type:
9779 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9780 key=
"scheduler.type",
9781 file_path=cluster_path,
9782 message=f
"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
9785 if scheduler_type !=
"slurm":
9787 ERROR_CODE_CFG_INVALID_VALUE,
9788 key=
"scheduler.type",
9789 file_path=cluster_path,
9790 message=f
"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.",
9794 if args.solve
and args.num_procs
not in (1, cluster_tasks):
9796 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9797 key=
"resources.ntasks_per_node",
9798 file_path=cluster_path,
9800 "--num-procs applies to the solver stage and must be 1 (auto) or "
9801 f
"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
9806 solver_num_procs_effective = cluster_tasks
9807 plan[
"launch_mode"] =
"slurm"
9808 plan[
"inputs"][
"cluster"] = cluster_path
9810 if getattr(args,
"scheduler",
None):
9811 fail_cli_usage(
"--scheduler requires --cluster in this version.")
9812 plan[
"launch_mode"] =
"local"
9816 if getattr(args,
'restart_from',
None):
9817 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
9818 if getattr(args,
'continue_run',
False)
and not args.post_process:
9819 print(
"[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
9822 case_path = os.path.abspath(args.case)
9823 solver_path = os.path.abspath(args.solver)
9824 monitor_path = os.path.abspath(args.monitor)
9828 validate_solver_configs(loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path)
9830 continue_mode = getattr(args,
'continue_run',
False)
9834 if not args.run_dir:
9836 run_dir = os.path.abspath(args.run_dir)
9837 if not os.path.isdir(run_dir):
9839 ERROR_CODE_CFG_FILE_NOT_FOUND,
9842 message=
"Specified run directory not found.",
9845 run_id = os.path.basename(run_dir)
9847 case_name = os.path.splitext(os.path.basename(case_path))[0]
9848 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
9849 run_id = f
"{case_name}_{timestamp}"
9850 run_dir = os.path.abspath(os.path.join(
"runs", run_id))
9854 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir
9856 except ValueError
as e:
9858 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9860 file_path=case_path,
9865 config_dir = os.path.join(run_dir,
"config")
9866 scheduler_dir = os.path.join(run_dir,
"scheduler")
9867 logs_dir = os.path.join(run_dir,
"logs")
9868 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
9869 profile_path = os.path.join(config_dir,
"profile.run")
9872 plan[
"run_id_preview"] = run_id
9873 plan[
"run_dir_preview"] = run_dir
9874 plan[
"inputs"].update({
"case": case_path,
"solver": solver_path,
"monitor": monitor_path})
9875 plan[
"artifacts"].extend(
9880 os.path.join(run_dir,
"output"),
9882 os.path.join(config_dir,
"case.yml"),
9883 os.path.join(config_dir,
"solver.yml"),
9884 os.path.join(config_dir,
"monitor.yml"),
9885 solver_control_path,
9886 os.path.join(run_dir,
"manifest.json"),
9893 plan[
"artifacts"].append(os.path.join(config_dir,
"whitelist.run"))
9894 if profiling_preview[
"mode"] ==
"selected":
9895 plan[
"artifacts"].append(profile_path)
9897 plan[
"artifacts"].extend(solve_diagnostics[
"artifacts"])
9899 plan[
"artifacts"].append(os.path.join(config_dir,
"cluster.yml"))
9900 plan[
"artifacts"].append(os.path.join(scheduler_dir,
"submission.json"))
9905 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
9910 config_search_anchor=case_path,
9911 extra_search_anchors=[cluster_path],
9913 plan[
"artifacts"].append(solver_script)
9914 plan[
"stages"][
"solve"] = {
9916 "script": solver_script,
9917 "num_procs_effective": solver_num_procs_effective,
9918 "launch_command": solver_cmd,
9925 solver_num_procs_effective,
9926 config_search_anchor=case_path,
9928 solver_stream_log = os.path.join(scheduler_dir, f
"{run_id}_solver.log")
9929 plan[
"artifacts"].append(solver_stream_log)
9930 plan[
"stages"][
"solve"] = {
9932 "num_procs_effective": solver_num_procs_effective,
9933 "stream_log": solver_stream_log,
9934 "launch_command": solver_cmd,
9937 if resolved_restart_source_dir:
9938 plan[
"stages"][
"solve"][
"restart_source_directory"] = resolved_restart_source_dir
9940 plan[
"stages"][
"solve"][
"continue_mode"] =
True
9942 if args.post_process:
9943 post_path = os.path.abspath(args.post)
9944 plan[
"inputs"][
"post"] = post_path
9949 run_dir = os.path.abspath(args.run_dir)
9950 if not os.path.isdir(run_dir):
9952 ERROR_CODE_CFG_FILE_NOT_FOUND,
9955 message=
"Specified run directory not found.",
9958 run_id = os.path.basename(run_dir)
9959 elif not args.solve:
9960 fail_cli_usage(
"--post-process requires --run-dir when not used with --solve.")
9963 config_dir = os.path.join(run_dir,
"config")
9965 if not all([case_path, monitor_path, solver_control_path]):
9967 ERROR_CODE_CFG_MISSING_KEY,
9968 key=
"run_dir.config",
9969 file_path=config_dir,
9971 "Could not auto-identify required run inputs "
9972 "(case.yml/monitor.yml/*.control) in run config directory."
9979 config_dir = os.path.join(run_dir,
"config")
9980 case_path = os.path.join(config_dir,
"case.yml")
9981 monitor_path = os.path.join(config_dir,
"monitor.yml")
9982 if solver_control_path
is None:
9983 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
9985 allow_source_frontier_scan =
not args.solve
9992 continue_requested=getattr(args,
'continue_run',
False),
9993 allow_source_frontier_scan=allow_source_frontier_scan,
9996 post_recipe_path = os.path.join(config_dir,
"post.run")
9997 output_dir_rel = post_cfg.get(
"io", {}).get(
"output_directory")
9998 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix")
9999 if not output_dir_rel
or not output_prefix:
10001 ERROR_CODE_CFG_MISSING_KEY,
10002 key=
"io.output_directory/io.output_filename_prefix",
10003 file_path=post_path,
10004 message=
"Missing required post IO keys.",
10007 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
10011 plan[
"artifacts"].extend(post_diagnostics[
"artifacts"])
10014 solver_control_path,
10015 "-postprocessing_config_file",
10018 plan[
"artifacts"].extend([
10021 post_plan[
"resume_state_path"],
10022 post_plan[
"lock_paths"][
"wrapper_path"],
10023 post_plan[
"lock_paths"][
"lock_file"],
10024 post_plan[
"lock_paths"][
"metadata_file"],
10026 plan[
"artifacts"].extend(statistics_output_paths)
10029 "source_data_directory": post_plan[
"source_data_directory"],
10030 "requested_start_step": post_plan[
"requested_start_step"],
10031 "requested_end_step": post_plan[
"requested_end_step"],
10032 "step_interval": post_plan[
"step_interval"],
10033 "resume_applied": bool(post_plan[
"continue_requested"]
and post_plan[
"resume_recipe_match"]),
10034 "resume_recipe_match": post_plan[
"resume_recipe_match"],
10035 "resume_bootstrapped": post_plan[
"resume_bootstrapped"],
10036 "resume_match_source": post_plan[
"resume_match_source"],
10037 "completed_frontier_step": post_plan[
"completed_frontier_step"],
10038 "source_frontier_step": post_plan[
"source_frontier_step"],
10039 "source_frontier_diagnostic": post_plan[
"source_frontier_diagnostic"],
10040 "source_frontier_deferred": post_plan[
"source_frontier_deferred"],
10041 "effective_start_step": post_plan[
"effective_start_step"],
10042 "effective_end_step": post_plan[
"effective_end_step"],
10043 "skip_reason": post_plan[
"skip_reason"],
10044 "post_skipped_as_complete": post_plan[
"skip_reason"] ==
"already-complete-window",
10045 "recipe_fingerprint": post_plan[
"recipe_fingerprint"],
10046 "num_procs_effective": post_num_procs_effective,
10049 if post_plan[
"skip_reason"]
is None:
10051 scheduler_dir = os.path.join(run_dir,
"scheduler")
10052 post_script = os.path.join(scheduler_dir,
"post.sbatch")
10058 config_search_anchor=case_path,
10059 extra_search_anchors=[cluster_path],
10060 force_num_procs=post_num_procs_effective,
10064 post_plan[
"recipe_fingerprint"],
10066 create_wrapper=
False,
10068 plan[
"artifacts"].append(post_script)
10069 stage_meta.update({
10071 "script": post_script,
10072 "launch_command": post_cmd,
10079 post_num_procs_effective,
10080 config_search_anchor=case_path,
10081 allow_single_rank_launcher_override=
True,
10082 force_num_procs=post_num_procs_effective,
10086 post_plan[
"recipe_fingerprint"],
10088 create_wrapper=
False,
10090 post_stream_log = os.path.join(run_dir,
"scheduler", f
"{run_id}_{output_prefix}.log")
10091 plan[
"artifacts"].append(post_stream_log)
10092 stage_meta.update({
10094 "stream_log": post_stream_log,
10095 "launch_command": post_cmd,
10099 stage_meta.update({
10100 "mode":
"slurm" if cluster_mode
else "local",
10101 "launch_command": [],
10102 "launch_command_string":
"",
10105 plan[
"stages"][
"post-process"] = stage_meta
10110 for item
in plan[
"artifacts"]:
10111 if item
not in seen:
10113 deduped.append(item)
10114 plan[
"artifacts"] = deduped
10115 if run_id
and "run_id_preview" not in plan:
10116 plan[
"run_id_preview"] = run_id
10117 if run_dir
and "run_dir_preview" not in plan:
10118 plan[
"run_dir_preview"] = run_dir
10119 plan[
"solver_num_procs_effective"] = solver_num_procs_effective
10120 plan[
"post_num_procs_effective"] = post_num_procs_effective
10121 plan[
"num_procs_effective"] = solver_num_procs_effective
10127 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
10128 @param[in,out] plan Dry-run plan to update.
10129 @param[in] case_cfg Parsed case configuration.
10130 @param[in] run_dir Preview run directory for relative artifact resolution.
10132 grid_cfg = case_cfg.get(
"grid", {})
10133 if not isinstance(grid_cfg, dict):
10136 mode = grid_cfg.get(
"mode")
10137 config_dir = os.path.join(run_dir,
"config")
10140 plan[
"artifacts"].append(os.path.join(config_dir,
"grid.run"))
10141 legacy_cfg = grid_cfg.get(
"legacy_conversion")
10142 if isinstance(legacy_cfg, dict)
and legacy_cfg.get(
"enabled",
True):
10143 output_file = legacy_cfg.get(
"output_file", os.path.join(
"config",
"grid.converted.picgrid"))
10144 if isinstance(output_file, str)
and output_file.strip():
10145 if not os.path.isabs(output_file):
10146 output_file = os.path.abspath(os.path.join(run_dir, output_file))
10147 plan[
"artifacts"].append(output_file)
10148 elif mode ==
"grid_gen":
10149 generator = grid_cfg.get(
"generator", {})
10150 if not isinstance(generator, dict):
10152 plan[
"artifacts"].append(os.path.join(config_dir,
"grid.run"))
10153 for key, default
in (
10154 (
"output_file", os.path.join(
"config",
"grid.generated.picgrid")),
10155 (
"stats_file",
None),
10156 (
"vts_file",
None),
10158 artifact_path = generator.get(key, default)
10159 if isinstance(artifact_path, str)
and artifact_path.strip():
10160 if not os.path.isabs(artifact_path):
10161 artifact_path = os.path.abspath(os.path.join(run_dir, artifact_path))
10162 plan[
"artifacts"].append(artifact_path)
10166 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
10167 @param[in,out] plan Dry-run plan to update.
10168 @param[in] case_cfg Parsed case configuration.
10169 @param[in] run_dir Preview run directory.
10175 config_dir = os.path.join(run_dir,
"config")
10176 has_generated =
False
10177 for block_idx, block
in enumerate(prepared_blocks):
10179 if bc.get(
"handler") !=
"prescribed_flow":
10181 source = (bc.get(
"params")
or {}).get(
"source", {})
10182 if source.get(
"type")
not in {
"generated",
"field_slice"}:
10184 has_generated =
True
10186 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
10187 default_output = os.path.join(
10188 "config", f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.picslice"
10192 source.get(
"output_file"),
10194 default_to_config_dir=
True,
10196 staged_path = os.path.join(config_dir, f
"inlet_profile_block{block_idx}_{face_token}.picslice")
10197 plan[
"artifacts"].append(generated_path)
10198 plan[
"artifacts"].append(staged_path)
10200 plan[
"artifacts"].append(os.path.join(config_dir,
"profile.info"))
10204 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
10205 @param[in,out] plan Dry-run plan receiving artifact paths.
10206 @param[in] case_cfg Parsed case configuration.
10207 @param[in] solver_cfg Parsed solver configuration.
10208 @param[in] run_dir Planned run directory.
10211 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
10213 start_step = int((case_cfg.get(
"run_control", {})
or {}).get(
"start_step", 0)
or 0)
10214 if source !=
"solve" or start_step != 0:
10218 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
10222 except (KeyError, ValueError):
10224 if resolved[
"kind"]
not in {
"file",
"ic_gen"}:
10226 config_dir = os.path.join(run_dir,
"config")
10227 plan[
"artifacts"].append(
10228 os.path.join(config_dir,
"initial_condition", f
"{resolved['field_name']}00000_0.dat")
10230 if resolved[
"kind"] ==
"ic_gen":
10231 if (case_cfg.get(
"grid", {})
or {}).get(
"mode") ==
"programmatic_c":
10232 plan[
"artifacts"].append(os.path.join(config_dir,
"grid.run"))
10234 run_dir, resolved.get(
"output_file"), os.path.join(
"config",
"initial_condition.generated.dat"),
10235 default_to_config_dir=
True,
10241 @brief Render dry-run plan in human or JSON format.
10242 @param[in] plan Argument passed to `render_run_dry_plan()`.
10243 @param[in] output_format Argument passed to `render_run_dry_plan()`.
10245 if output_format ==
"json":
10246 print(json.dumps(plan, indent=2, sort_keys=
True))
10249 print(
"\n" +
"=" * 60)
10250 print(
" DRY-RUN PLAN")
10252 print(f
" Launch mode : {plan.get('launch_mode')}")
10253 print(f
" Created at : {plan.get('created_at')}")
10254 if plan.get(
"run_id_preview"):
10255 print(f
" Run ID preview : {plan.get('run_id_preview')}")
10256 if plan.get(
"run_dir_preview"):
10257 print(f
" Run dir preview: {plan.get('run_dir_preview')}")
10258 print(f
" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
10259 print(f
" Post MPI procs : {plan.get('post_num_procs_effective')}")
10260 if plan.get(
"warnings"):
10261 print(
" Warnings :")
10262 for warning
in plan[
"warnings"]:
10263 print(f
" - {warning}")
10265 if plan.get(
"inputs"):
10266 print(
"\n Inputs:")
10267 for key, value
in plan[
"inputs"].items():
10268 print(f
" - {key}: {value}")
10270 if plan.get(
"stages"):
10271 print(
"\n Planned stage commands:")
10272 for stage, details
in plan[
"stages"].items():
10273 print(f
" - {stage} ({details.get('mode')}):")
10274 if details.get(
'skip_reason'):
10275 print(f
" skipped: {details.get('skip_reason')}")
10277 print(f
" {details.get('launch_command_string')}")
10279 diagnostics_artifacts = [item
for item
in plan.get(
"artifacts", [])
if "PETSc_" in os.path.basename(str(item))
or os.path.basename(str(item)) ==
"Runtime_Memory.log"]
10280 if diagnostics_artifacts:
10281 print(
"\n Diagnostics artifacts:")
10282 for artifact
in diagnostics_artifacts:
10283 print(f
" - {artifact}")
10285 print(
"\n Planned artifacts (no files created in dry-run):")
10286 for artifact
in plan.get(
"artifacts", []):
10287 print(f
" - {artifact}")
10293 @brief Implements `picurv validate` without launching solver/post workflows.
10294 @param[in] args Command-line style argument list supplied to the function.
10297 solver_group_selected = any([args.case, args.solver, args.monitor])
10298 any_group_selected = solver_group_selected
or any([args.post, args.cluster, args.study])
10300 cluster_path =
None
10302 if not any_group_selected:
10304 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
10305 hint=
"Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
10308 if solver_group_selected
and not all([args.case, args.solver, args.monitor]):
10309 fail_cli_usage(
"When solver validation is requested, --case, --solver, and --monitor are all required.")
10312 restart_from = getattr(args,
'restart_from',
None)
10313 continue_run = getattr(args,
'continue_run',
False)
10314 run_dir_val = getattr(args,
'run_dir',
None)
10315 if not solver_group_selected:
10317 print(
"[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
10318 if continue_run
and not args.post:
10319 print(
"[WARNING] --continue has no effect without solver configs or --post and will be ignored.", file=sys.stderr)
10320 if continue_run
and not run_dir_val:
10323 if solver_group_selected:
10324 case_path = os.path.abspath(args.case)
10325 solver_path = os.path.abspath(args.solver)
10326 monitor_path = os.path.abspath(args.monitor)
10331 checked.extend([case_path, solver_path, monitor_path])
10334 if restart_from
or continue_run:
10335 target_run_dir = os.path.abspath(run_dir_val)
if run_dir_val
else os.path.abspath(
"runs/_validate_dummy")
10338 print(
"[SUCCESS] Restart source validation passed.")
10339 except ValueError
as e:
10340 print(f
"[ERROR] Restart validation failed: {e}", file=sys.stderr)
10345 post_path = os.path.abspath(args.post)
10348 checked.append(post_path)
10352 cluster_path = os.path.abspath(args.cluster)
10355 checked.append(cluster_path)
10360 extra_search_anchors=[cluster_path]
if cluster_path
else None,
10362 except ValueError
as exc:
10364 ERROR_CODE_CFG_INVALID_VALUE,
10365 key=
"runtime_execution",
10366 file_path=case_path
or cluster_path
or os.getcwd(),
10370 if runtime_execution_path:
10371 checked.append(runtime_execution_path)
10375 study_path = os.path.abspath(args.study)
10378 checked.append(study_path)
10380 if post_cfg
is not None and run_dir_val:
10381 post_path = os.path.abspath(args.post)
10382 validate_run_dir = os.path.abspath(run_dir_val)
10383 if os.path.isdir(validate_run_dir):
10384 monitor_for_post = monitor_cfg
if solver_group_selected
else None
10385 if monitor_for_post
is None:
10386 config_dir_candidate = os.path.join(validate_run_dir,
"config")
10387 monitor_candidate = os.path.join(config_dir_candidate,
"monitor.yml")
10388 if os.path.isfile(monitor_candidate):
10390 if monitor_for_post
is not None:
10392 if os.path.isdir(resolved_source)
and os.listdir(resolved_source):
10393 print(f
"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
10395 print(f
"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
10397 if args.strict
and post_cfg
is not None:
10398 post_path = os.path.abspath(args.post)
10399 source_dir = post_cfg.get(
"source_data", {}).get(
"directory")
10400 if source_dir
and source_dir !=
"<solver_output_dir>":
10402 if not os.path.isdir(resolved):
10404 ERROR_CODE_CFG_FILE_NOT_FOUND,
10405 key=
"source_data.directory",
10406 file_path=post_path,
10407 message=f
"strict mode: source_data.directory resolves to missing directory '{resolved}'.",
10411 if args.strict
and study_cfg
is not None:
10412 study_path = os.path.abspath(args.study)
10413 base_cfgs = study_cfg.get(
"base_configs", {})
10414 if isinstance(base_cfgs, dict):
10415 base_case_path =
resolve_path(study_path, base_cfgs.get(
"case"))
10416 base_solver_path =
resolve_path(study_path, base_cfgs.get(
"solver"))
10417 base_monitor_path =
resolve_path(study_path, base_cfgs.get(
"monitor"))
10418 base_post_path =
resolve_path(study_path, base_cfgs.get(
"post"))
10419 if all([base_case_path, base_solver_path, base_monitor_path]):
10431 print(f
"[SUCCESS] Validation completed for {len(checked)} file(s).")
10432 for path
in checked:
10433 print(f
" - {path}")
10437 @brief Generate deterministic case artifacts without launching solver/post stages.
10438 @param[in] args Parsed precompute command arguments.
10440 case_path = os.path.abspath(args.case)
10442 case_name = os.path.splitext(os.path.basename(case_path))[0]
10443 output_dir = args.output_dir
or os.path.join(
"precomputed", case_name)
10444 output_dir = os.path.abspath(output_dir)
10445 config_dir = os.path.join(output_dir,
"config")
10446 os.makedirs(config_dir, exist_ok=
True)
10448 print(f
"[INFO] Precomputing deterministic artifacts for case: {case_path}")
10449 print(f
"[INFO] Output directory: {output_dir}")
10453 grid_cfg = case_cfg.get(
"grid", {})
or {}
10454 grid_mode = grid_cfg.get(
"mode")
10455 scaling = (case_cfg.get(
"properties", {})
or {}).get(
"scaling", {})
or {}
10456 length_ref = float(scaling.get(
"length_ref", 1.0))
10457 expected_nblk = int((case_cfg.get(
"models", {})
or {}).get(
"domain", {}).get(
"blocks", 1))
10458 staged_grid = os.path.join(config_dir,
"grid.run")
10459 if grid_mode ==
"grid_gen":
10460 print(
"[INFO] Precomputing grid via grid.gen...")
10462 artifacts.append(os.path.abspath(generated_grid))
10464 artifacts.append(os.path.abspath(staged_grid))
10465 elif grid_mode ==
"file":
10467 if isinstance(grid_cfg.get(
"legacy_conversion"), dict):
10470 artifacts.append(os.path.abspath(staged_grid))
10471 print(f
"[INFO] Precomputed validated file grid: {staged_grid}")
10472 elif grid_mode ==
"programmatic_c":
10473 print(f
"[INFO] Grid mode '{grid_mode}' does not require precomputed grid generation.")
10475 raise ValueError(f
"Unsupported grid.mode '{grid_mode}' for precompute.")
10478 artifacts.extend(summary[
"path"]
for summary
in profile_summaries)
10479 if profile_summaries:
10480 artifacts.append(os.path.join(config_dir,
"profile.info"))
10482 initial_condition =
None
10484 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
10486 U_ref=float((case_cfg.get(
"properties", {}).get(
"scaling", {})
or {}).get(
"velocity_ref", 1.0)),
10488 if resolved_ic[
"kind"] ==
"ic_gen":
10489 if grid_mode ==
"programmatic_c":
10492 grid_cfg.get(
'programmatic_settings', {}), staged_grid, length_ref
10494 artifacts.append(os.path.abspath(staged_grid))
10496 f
"[INFO] Materialized programmatic grid.run for ic_gen: {staged_grid} "
10497 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
10499 except Exception
as e:
10500 raise RuntimeError(f
"Failed to generate grid.run for ic_gen: {e}")
from e
10502 artifacts.extend([initial_condition[
"source"], initial_condition[
"staged"]])
10503 elif resolved_ic[
"kind"] ==
"file":
10504 print(
"[INFO] File initial condition does not require generated precompute output.")
10506 print(f
"[INFO] Built-in initial-condition generator '{resolved_ic['label']}' runs in the C solver.")
10510 "output_dir": output_dir,
10511 "grid_mode": grid_mode,
10512 "artifacts": artifacts,
10513 "profiles": profile_summaries,
10514 "initial_condition": initial_condition,
10516 manifest_path = os.path.join(config_dir,
"precompute.manifest.json")
10518 print(f
"[SUCCESS] Wrote precompute manifest: {os.path.relpath(manifest_path)}")
10519 print(f
"[SUCCESS] Precompute completed with {len(artifacts)} artifact(s).")
10523 @brief Main orchestrator for the 'run' command (local and Slurm modes).
10524 @param[in] args Command-line style argument list supplied to the function.
10526 if getattr(args,
"dry_run",
False):
10533 output_dir_abs =
None
10534 statistics_output_paths = []
10535 workflow_start = time.time()
10536 stages_completed = []
10538 submission_meta = {
"launch_mode":
"local",
"no_submit": bool(args.no_submit),
"stages": {}}
10540 cluster_mode = bool(getattr(args,
"cluster",
None))
10542 cluster_path =
None
10543 solver_num_procs_effective = args.num_procs
10544 post_num_procs_effective = 1
10547 cluster_path = os.path.abspath(args.cluster)
10550 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
10551 if args.scheduler
and args.scheduler.lower() != scheduler_type:
10553 f
"[FATAL] --scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
10557 if scheduler_type !=
"slurm":
10558 print(f
"[FATAL] Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.", file=sys.stderr)
10561 if args.solve
and args.num_procs
not in (1, cluster_tasks):
10563 "[FATAL] In cluster mode, --num-procs applies to the solver stage and must be "
10564 f
"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
10569 solver_num_procs_effective = cluster_tasks
10570 submission_meta[
"launch_mode"] =
"slurm"
10571 submission_meta[
"cluster_config"] = cluster_path
10572 submission_meta[
"no_submit"] = bool(args.no_submit)
10575 f
"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks "
10576 f
"from cluster.yml; post stage defaults to {post_num_procs_effective} task."
10579 print(f
"[INFO] Cluster mode enabled (Slurm). Post stage defaults to {post_num_procs_effective} task.")
10580 elif getattr(args,
"scheduler",
None):
10581 print(
"[FATAL] --scheduler requires --cluster in this version.", file=sys.stderr)
10586 if getattr(args,
'restart_from',
None):
10587 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
10588 if getattr(args,
'continue_run',
False)
and not args.post_process:
10589 print(
"[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
10595 'case':
read_yaml_file(args.case),
'case_path': os.path.abspath(args.case),
10596 'solver':
read_yaml_file(args.solver),
'solver_path': os.path.abspath(args.solver),
10597 'monitor':
read_yaml_file(args.monitor),
'monitor_path': os.path.abspath(args.monitor),
10598 'walltime_guard_policy': walltime_guard_policy,
10601 print(
"\n[INFO] Validating configuration files...")
10603 configs[
'case'], configs[
'solver'], configs[
'monitor'],
10604 args.case, args.solver, args.monitor
10606 print(
"[SUCCESS] All configuration files passed validation.\n")
10608 continue_mode = getattr(args,
'continue_run',
False)
10611 if not args.run_dir:
10613 run_dir = os.path.abspath(args.run_dir)
10614 if not os.path.isdir(run_dir):
10616 ERROR_CODE_CFG_FILE_NOT_FOUND,
10619 message=
"Specified run directory not found.",
10622 run_id = os.path.basename(run_dir)
10624 case_name = os.path.splitext(os.path.basename(args.case))[0]
10625 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
10626 run_id = f
"{case_name}_{timestamp}"
10627 run_dir = os.path.abspath(os.path.join(
"runs", run_id))
10631 args, configs[
"case"], configs[
"solver"], configs[
"monitor"], run_dir
10633 except ValueError
as e:
10635 ERROR_CODE_CFG_INCONSISTENT_COMBO,
10637 file_path=args.case,
10642 config_dir = os.path.join(run_dir,
"config")
10643 if not continue_mode:
10644 for d
in [config_dir, os.path.join(run_dir,
"scheduler")]:
10645 os.makedirs(d, exist_ok=
True)
10647 os.makedirs(config_dir, exist_ok=
True)
10649 print(f
"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
10651 print(f
"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
10653 shutil.copy(args.case, os.path.join(config_dir,
"case.yml"))
10654 shutil.copy(args.solver, os.path.join(config_dir,
"solver.yml"))
10655 shutil.copy(args.monitor, os.path.join(config_dir,
"monitor.yml"))
10657 shutil.copy(cluster_path, os.path.join(config_dir,
"cluster.yml"))
10659 print(
"\n" +
"="*25 +
" SOLVER STAGE " +
"="*25)
10660 source_files = {
'Case': args.case,
'Solver': args.solver,
'Monitor': args.monitor}
10662 if resolved_restart_source_dir:
10663 print(f
"[INFO] Restart source: {resolved_restart_source_dir}")
10665 print(
"[INFO] Continue mode: logs will be appended, not overwritten.")
10670 solver_num_procs_effective,
10672 restart_source_dir=resolved_restart_source_dir,
10673 continue_mode=is_continue,
10679 scheduler_dir = os.path.join(run_dir,
"scheduler")
10680 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
10681 solver_log = os.path.join(scheduler_dir,
"solver_%j.out")
10682 solver_err = os.path.join(scheduler_dir,
"solver_%j.err")
10687 config_search_anchor=args.case,
10688 extra_search_anchors=[cluster_path],
10698 env_vars={
"LOG_LEVEL": configs[
'monitor'].get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
10701 submission_meta[
"stages"][
"solve"] = {
10702 "script": solver_script,
10703 "submitted":
False,
10704 "num_procs_effective": solver_num_procs_effective,
10706 print(f
"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
10707 if not args.no_submit:
10709 submission_meta[
"stages"][
"solve"].update(submit_info)
10710 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
10711 print(f
"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
10712 stages_completed.append(
'solve')
10717 solver_num_procs_effective,
10718 config_search_anchor=configs[
"case_path"],
10720 solver_log = os.path.join(
"scheduler", f
"{run_id}_solver.log")
10721 submission_meta[
"stages"][
"solve"] = {
10722 "command": command,
10724 "log_file": solver_log,
10725 "submitted":
False,
10726 "num_procs_effective": solver_num_procs_effective,
10729 print(f
"[SUCCESS] Staged local solver command: {solver_log}")
10732 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
10733 submission_meta[
"stages"][
"solve"][
"executed"] =
True
10734 submission_meta[
"stages"][
"solve"][
"completed_at"] = datetime.now().isoformat()
10735 stages_completed.append(
'solve')
10738 if args.post_process:
10740 run_dir = os.path.abspath(args.run_dir)
10741 if not os.path.isdir(run_dir):
10742 print(f
"[FATAL] Specified run directory not found: {run_dir}", file=sys.stderr)
10744 print(f
"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
10745 run_id = os.path.basename(run_dir)
10746 elif not args.solve:
10747 print(
"[FATAL] --post-process requires --run-dir when not used with --solve.", file=sys.stderr)
10750 print(
"\n" +
"="*20 +
" POST-PROCESSING STAGE " +
"="*20)
10751 config_dir = os.path.join(run_dir,
"config")
10754 if not all([case_path, monitor_path, solver_control_path]):
10755 print(f
"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
10757 print(
" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
10758 if not monitor_path:
10759 print(
" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
10760 if not solver_control_path:
10761 print(
" - No '.control' file found.", file=sys.stderr)
10764 print(f
"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
10765 print(f
"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
10771 print(
"[INFO] Validating post-processing configuration...")
10773 print(
"[SUCCESS] Post-processing configuration passed validation.\n")
10775 solver_sources_deferred = bool(args.solve
and (cluster_mode
or args.no_submit))
10776 allow_source_frontier_scan =
not solver_sources_deferred
10783 continue_requested=getattr(args,
'continue_run',
False),
10784 allow_source_frontier_scan=allow_source_frontier_scan,
10788 if source_template ==
'<solver_output_dir>':
10789 print(f
"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
10791 print(f
"[INFO] Post-processor source data (user-defined): {os.path.relpath(post_plan['source_data_directory'])}")
10793 if getattr(args,
'continue_run',
False):
10794 if post_plan[
'resume_recipe_match']:
10795 print(f
"[INFO] Post resume recipe match: yes ({post_plan['resume_match_source']}).")
10797 print(
"[INFO] Post resume recipe match: no. Using the configured start_step for this recipe.")
10798 if post_plan[
'completed_frontier_step']
is not None:
10799 print(f
"[INFO] Completed post frontier: step {post_plan['completed_frontier_step']}")
10801 print(
"[INFO] Completed post frontier: none")
10802 if post_plan[
'source_frontier_deferred']:
10803 print(
"[INFO] Source availability frontier: deferred because the solver stage will populate the requested window before post starts.")
10804 elif post_plan[
'source_frontier_step']
is not None:
10805 print(f
"[INFO] Current source availability frontier: step {post_plan['source_frontier_step']}")
10807 print(
"[INFO] Current source availability frontier: none")
10811 if post_plan[
'skip_reason'] ==
'already-complete-window':
10812 print(
"[INFO] Requested post window is already complete; skipping postprocessor launch.")
10814 elif post_plan[
'skip_reason'] ==
'already-caught-up-to-current-source-frontier':
10815 print(
"[INFO] Post outputs are already caught up to the current fully available source frontier; nothing new to launch right now.")
10816 diagnostic = post_plan.get(
'source_frontier_diagnostic')
or {}
10817 first_incomplete = diagnostic.get(
'first_incomplete_step')
10818 if first_incomplete
is not None:
10819 print(f
"[INFO] First incomplete requested source step: {first_incomplete}")
10821 "[INFO] Closest complete source steps: "
10822 f
"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10823 f
"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10825 elif post_plan[
'skip_reason'] ==
'nothing-available-yet':
10826 diagnostic = post_plan.get(
'source_frontier_diagnostic')
or {}
10827 first_incomplete = diagnostic.get(
'first_incomplete_step')
10828 if first_incomplete
is not None:
10830 f
"[INFO] First requested source step {first_incomplete} is incomplete; "
10831 "skipping postprocessor launch for now."
10834 "[INFO] Closest complete source steps: "
10835 f
"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10836 f
"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10838 missing_files = diagnostic.get(
'missing_files_for_first_incomplete_step')
or []
10840 print(f
"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
10842 print(
"[INFO] No fully available source steps exist yet in the requested window; skipping postprocessor launch for now.")
10845 f
"[INFO] Effective post window: {post_plan['effective_start_step']}..{post_plan['effective_end_step']} "
10846 f
"(stride {post_plan['step_interval']})"
10849 post_effective_cfg = post_plan[
'effective_post_cfg']
10850 post_io_cfg = post_effective_cfg.get(
'io', {})
10852 output_dir_rel = post_io_cfg[
'output_directory']
10853 output_prefix = post_io_cfg[
'output_filename_prefix']
10854 except KeyError
as e:
10855 print(f
"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
10858 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
10859 os.makedirs(output_dir_abs, exist_ok=
True)
10860 print(f
"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
10862 for stats_path
in statistics_output_paths:
10863 print(f
"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
10865 source_files_post = {
'Case': case_path,
'Post-Profile': args.post}
10871 solver_control_path,
10872 "-postprocessing_config_file",
10876 scheduler_dir = os.path.join(run_dir,
"scheduler")
10877 os.makedirs(scheduler_dir, exist_ok=
True)
10878 post_script = os.path.join(scheduler_dir,
"post.sbatch")
10879 post_log = os.path.join(scheduler_dir,
"post_%j.out")
10880 post_err = os.path.join(scheduler_dir,
"post_%j.err")
10886 config_search_anchor=case_path,
10887 extra_search_anchors=[cluster_path],
10888 force_num_procs=post_num_procs_effective,
10892 post_plan[
'recipe_fingerprint'],
10894 create_wrapper=
True,
10904 env_vars={
"LOG_LEVEL": monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
10906 submission_meta[
"stages"][
"post-process"] = {
10907 "script": post_script,
10908 "submitted":
False,
10909 "num_procs_effective": post_num_procs_effective,
10910 "resume_recipe_match": post_plan[
'resume_recipe_match'],
10911 "resume_bootstrapped": post_plan[
'resume_bootstrapped'],
10912 "resume_match_source": post_plan[
'resume_match_source'],
10913 "effective_start_step": post_plan[
'effective_start_step'],
10914 "effective_end_step": post_plan[
'effective_end_step'],
10915 "completed_frontier_step": post_plan[
'completed_frontier_step'],
10916 "source_frontier_step": post_plan[
'source_frontier_step'],
10917 "source_frontier_deferred": post_plan[
'source_frontier_deferred'],
10918 "recipe_fingerprint": post_plan[
'recipe_fingerprint'],
10920 print(f
"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
10922 if not args.no_submit:
10923 dependency_job =
None
10925 dependency_job = submission_meta.get(
"stages", {}).get(
"solve", {}).get(
"job_id")
10926 submit_info =
submit_sbatch(post_script, dependency=dependency_job)
10927 submission_meta[
"stages"][
"post-process"].update(submit_info)
10928 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
10930 submission_meta[
"stages"][
"post-process"][
"dependency"] = f
"afterok:{dependency_job}"
10931 print(f
"[SUCCESS] Submitted post job: {submit_info['job_id']}")
10932 stages_completed.append(
'post-process')
10937 post_num_procs_effective,
10938 config_search_anchor=case_path,
10939 allow_single_rank_launcher_override=
True,
10940 force_num_procs=post_num_procs_effective,
10944 post_plan[
'recipe_fingerprint'],
10946 create_wrapper=
True,
10948 post_log = os.path.join(
"scheduler", f
"{run_id}_{output_prefix}.log")
10949 submission_meta[
"stages"][
"post-process"] = {
10950 "command": command,
10952 "log_file": post_log,
10953 "submitted":
False,
10954 "num_procs_effective": post_num_procs_effective,
10955 "resume_recipe_match": post_plan[
'resume_recipe_match'],
10956 "resume_bootstrapped": post_plan[
'resume_bootstrapped'],
10957 "resume_match_source": post_plan[
'resume_match_source'],
10958 "effective_start_step": post_plan[
'effective_start_step'],
10959 "effective_end_step": post_plan[
'effective_end_step'],
10960 "completed_frontier_step": post_plan[
'completed_frontier_step'],
10961 "source_frontier_step": post_plan[
'source_frontier_step'],
10962 "source_frontier_deferred": post_plan[
'source_frontier_deferred'],
10963 "recipe_fingerprint": post_plan[
'recipe_fingerprint'],
10966 print(f
"[SUCCESS] Staged local post command: {post_log}")
10970 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
10971 submission_meta[
"stages"][
"post-process"][
"executed"] =
True
10972 submission_meta[
"stages"][
"post-process"][
"completed_at"] = datetime.now().isoformat()
10973 stages_completed.append(
'post-process')
10978 "created_at": datetime.now().isoformat(),
10979 "launch_mode":
"slurm" if cluster_mode
else "local",
10981 "num_procs": solver_num_procs_effective,
10982 "solver_num_procs": solver_num_procs_effective,
10983 "post_num_procs": post_num_procs_effective,
10984 "stages_requested": {
"solve": bool(args.solve),
"post_process": bool(args.post_process)},
10985 "stages_completed_or_submitted": stages_completed,
10989 manifest[
"inputs"][
"case"] = os.path.abspath(args.case)
10990 manifest[
"inputs"][
"solver"] = os.path.abspath(args.solver)
10991 manifest[
"inputs"][
"monitor"] = os.path.abspath(args.monitor)
10992 if args.post_process:
10993 manifest[
"inputs"][
"post"] = os.path.abspath(args.post)
10995 manifest[
"inputs"][
"cluster"] = cluster_path
10996 if submission_meta.get(
"stages"):
10997 write_json_file(os.path.join(run_dir,
"scheduler",
"submission.json"), submission_meta)
11000 if stages_completed:
11001 elapsed = time.time() - workflow_start
11002 mins, secs = divmod(int(elapsed), 60)
11003 hrs, mins = divmod(mins, 60)
11005 time_str = f
"{hrs}h {mins}m {secs}s"
11007 time_str = f
"{mins}m {secs}s"
11009 time_str = f
"{secs}s"
11011 print(
"\n" +
"=" * 60)
11012 print(
" RUN SUMMARY")
11014 print(f
" Run ID : {run_id}")
11015 print(f
" Run directory : {os.path.relpath(run_dir)}")
11016 print(f
" Wall-clock : {time_str}")
11017 print(f
" Stages : {', '.join(stages_completed)}")
11018 print(f
" Launch mode : {'slurm' if cluster_mode else 'local'}")
11020 print(f
" Solver MPI procs: {solver_num_procs_effective}")
11021 if args.post_process:
11022 print(f
" Post MPI procs : {post_num_procs_effective}")
11023 if args.solve
and configs:
11024 total_steps = configs[
'case'].get(
'run_control', {}).get(
'total_steps',
'?')
11025 result_dir = os.path.join(run_dir, configs[
'monitor'].get(
'io', {}).get(
'directories', {}).get(
'output',
'output'))
11026 print(f
" Steps run : {total_steps}")
11027 print(f
" Solver output : {os.path.relpath(result_dir)}")
11028 if 'post-process' in stages_completed
and output_dir_abs:
11029 print(f
" Post output : {os.path.relpath(output_dir_abs)}")
11030 for stats_path
in statistics_output_paths:
11031 print(f
" Stats output : {os.path.relpath(stats_path)}")
11032 print(f
" Logs : {os.path.relpath(os.path.join(run_dir, 'logs'))}")
11033 if cluster_mode
or submission_meta.get(
"stages"):
11034 submission_file = os.path.join(run_dir,
"scheduler",
"submission.json")
11035 print(f
" Submission meta: {os.path.relpath(submission_file)}")
11041 @brief Parse a case_index.tsv file back into a list of case entry dicts.
11042 @param[in] tsv_path Path to the case_index.tsv file.
11043 @return List of dicts with keys: index, case_id, run_dir, control_file,
11044 post_recipe_file, log_level, post_prefix.
11047 with open(tsv_path)
as f:
11049 line = line.strip()
11052 parts = line.split(
"\t")
11054 "index": int(parts[0]),
11055 "case_id": parts[1],
11056 "run_dir": parts[2],
11057 "control_file": parts[3],
11058 "post_recipe_file": parts[4],
11059 "log_level": parts[5],
11060 "post_prefix": parts[6],
11067 @brief Study/sweep orchestration using Slurm job arrays.
11068 @param[in] args Command-line style argument list supplied to the function.
11070 study_path = os.path.abspath(args.study)
11071 cluster_path = os.path.abspath(args.cluster)
11078 study_name = os.path.splitext(os.path.basename(study_path))[0]
11079 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
11080 study_id = f
"{study_name}_{timestamp}"
11081 study_dir = os.path.abspath(os.path.join(
"studies", study_id))
11082 cases_dir = os.path.join(study_dir,
"cases")
11083 scheduler_dir = os.path.join(study_dir,
"scheduler")
11084 results_dir = os.path.join(study_dir,
"results")
11085 for path
in [cases_dir, scheduler_dir, results_dir]:
11086 os.makedirs(path, exist_ok=
True)
11088 print(f
"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
11089 shutil.copy(study_path, os.path.join(study_dir,
"study.yml"))
11090 shutil.copy(cluster_path, os.path.join(study_dir,
"cluster.yml"))
11092 base_cfgs = study_cfg[
"base_configs"]
11093 base_paths = {k:
resolve_path(study_path, v)
for k, v
in base_cfgs.items()}
11098 validate_solver_configs(base_case, base_solver, base_monitor, base_paths[
"case"], base_paths[
"solver"], base_paths[
"monitor"])
11102 if not combinations:
11103 print(
"[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
11105 print(f
"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
11109 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
11111 for idx, combo
in enumerate(combinations):
11112 case_id = f
"case_{idx:04d}"
11113 run_dir = os.path.join(cases_dir, case_id)
11114 config_dir = os.path.join(run_dir,
"config")
11115 os.makedirs(config_dir, exist_ok=
True)
11116 os.makedirs(os.path.join(run_dir,
"logs"), exist_ok=
True)
11117 os.makedirs(os.path.join(run_dir,
"output"), exist_ok=
True)
11119 case_cfg = copy.deepcopy(base_case)
11120 solver_cfg = copy.deepcopy(base_solver)
11121 monitor_cfg = copy.deepcopy(base_monitor)
11122 post_cfg = copy.deepcopy(base_post)
11123 target_map = {
"case": case_cfg,
"solver": solver_cfg,
"monitor": monitor_cfg,
"post": post_cfg}
11124 for full_key, value
in combo.items():
11125 root, nested = full_key.split(
".", 1)
11126 _deep_set(target_map[root], nested, value)
11132 case_path = os.path.join(config_dir,
"case.yml")
11133 solver_path = os.path.join(config_dir,
"solver.yml")
11134 monitor_path = os.path.join(config_dir,
"monitor.yml")
11135 post_path = os.path.join(config_dir,
"post.yml")
11144 source_files = {
'Case': case_path,
'Solver': solver_path,
'Monitor': monitor_path}
11147 "case": case_cfg,
"case_path": case_path,
11148 "solver": solver_cfg,
"solver_path": solver_path,
11149 "monitor": monitor_cfg,
"monitor_path": monitor_path,
11155 if not isinstance(post_cfg.get(
'source_data'), dict):
11156 post_cfg[
'source_data'] = {}
11157 post_cfg[
'source_data'][
'directory'] = source_dir
11158 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix",
"post")
11159 post_recipe =
generate_post_recipe_file(run_dir, case_id, post_cfg, {
'Case': case_path,
'Post-Profile': post_path}, monitor_cfg)
11161 case_entries.append({
11163 "case_id": case_id,
11164 "run_dir": os.path.abspath(run_dir),
11165 "control_file": control_file,
11166 "post_recipe_file": post_recipe,
11167 "log_level": str(monitor_cfg.get(
"logging", {}).get(
"verbosity",
"INFO")).upper(),
11168 "post_prefix": output_prefix,
11171 "parameters": combo,
11174 with open(case_index_file,
"w")
as f:
11175 for entry
in case_entries:
11179 str(entry[
"index"]),
11182 entry[
"control_file"],
11183 entry[
"post_recipe_file"],
11184 entry[
"log_level"],
11185 entry[
"post_prefix"],
11186 entry[
"solve_diagnostic_args"],
11187 entry[
"post_diagnostic_args"],
11191 print(f
"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
11193 max_idx = len(case_entries) - 1
11194 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
11195 array_spec = f
"0-{max_idx}"
11197 array_spec = f
"{array_spec}%{max_conc}"
11201 solver_array_script = os.path.join(scheduler_dir,
"solver_array.sbatch")
11202 post_array_script = os.path.join(scheduler_dir,
"post_array.sbatch")
11204 solver_array_script,
11205 f
"{study_id}_solve",
11212 os.path.join(scheduler_dir,
"solver_%A_%a.out"),
11213 os.path.join(scheduler_dir,
"solver_%A_%a.err")
11217 f
"{study_id}_post",
11224 os.path.join(scheduler_dir,
"post_%A_%a.out"),
11225 os.path.join(scheduler_dir,
"post_%A_%a.err")
11227 print(f
"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
11229 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
11230 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_aggregate.sbatch")
11232 metrics_aggregate_script,
11233 f
"{study_id}_metrics",
11238 print(f
"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
11241 "launch_mode":
"slurm",
11242 "study_id": study_id,
11243 "solver_array": {
"script": solver_array_script,
"submitted":
False},
11244 "post_array": {
"script": post_array_script,
"submitted":
False},
11245 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
11246 "no_submit": bool(args.no_submit),
11248 if not args.no_submit:
11250 submission[
"solver_array"].update(solver_submit)
11251 submission[
"solver_array"][
"submitted"] =
True
11252 post_submit =
submit_sbatch(post_array_script, dependency=solver_submit[
"job_id"])
11253 submission[
"post_array"].update(post_submit)
11254 submission[
"post_array"][
"submitted"] =
True
11255 submission[
"post_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
11256 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
11257 submission[
"metrics_aggregate"].update(metrics_submit)
11258 submission[
"metrics_aggregate"][
"submitted"] =
True
11259 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
11260 print(f
"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
11261 print(f
"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
11262 print(f
"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
11268 "study_id": study_id,
11269 "created_at": datetime.now().isoformat(),
11271 "study_type": study_cfg.get(
"study_type"),
11272 "num_cases": len(case_entries),
11274 "study_dir": study_dir,
11275 "case_index": case_index_file,
11276 "solver_array_script": solver_array_script,
11277 "post_array_script": post_array_script,
11278 "metrics_table": metrics_csv,
11279 "plots_dir": os.path.join(results_dir,
"plots"),
11281 "submission": submission,
11283 write_json_file(os.path.join(scheduler_dir,
"submission.json"), submission)
11284 write_json_file(os.path.join(study_dir,
"study_manifest.json"), summary)
11285 write_json_file(os.path.join(results_dir,
"summary.json"), {
"study_id": study_id,
"metrics_csv": metrics_csv,
"plots": plots})
11287 print(
"\n" +
"=" * 60)
11288 print(
" STUDY SUMMARY")
11290 print(f
" Study ID : {study_id}")
11291 print(f
" Study directory : {os.path.relpath(study_dir)}")
11292 print(f
" Cases generated : {len(case_entries)}")
11293 print(f
" Array spec : {array_spec}")
11294 print(f
" Solver script : {os.path.relpath(solver_array_script)}")
11295 print(f
" Post script : {os.path.relpath(post_array_script)}")
11297 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
11299 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
11305 @brief Continue a partially-completed Slurm parameter sweep study.
11306 @details Detects incomplete cases, prepares them for continuation (updating
11307 start_step, populating restart directories, regenerating control files),
11308 and submits new solver/post/metrics Slurm jobs. If all cases are already
11309 complete, performs metrics aggregation automatically.
11310 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
11312 study_dir = os.path.abspath(args.study_dir)
11313 manifest_path = os.path.join(study_dir,
"study_manifest.json")
11314 if not os.path.isfile(manifest_path):
11315 print(f
"[FATAL] Study manifest not found: {manifest_path}", file=sys.stderr)
11318 study_id = manifest[
"study_id"]
11320 study_path = os.path.join(study_dir,
"study.yml")
11321 cluster_path = os.path.abspath(args.cluster)
if args.cluster
else os.path.join(study_dir,
"cluster.yml")
11328 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir,
"cluster.yml"))
11329 print(f
"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
11331 scheduler_dir = os.path.join(study_dir,
"scheduler")
11332 cases_dir = os.path.join(study_dir,
"cases")
11333 results_dir = os.path.join(study_dir,
"results")
11334 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
11335 if not os.path.isfile(case_index_file):
11336 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
11341 base_cfgs = study_cfg[
"base_configs"]
11342 base_paths = {k:
resolve_path(study_path, v)
for k, v
in base_cfgs.items()}
11346 if len(combinations) != len(parsed_entries):
11348 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11349 f
"case_index.tsv ({len(parsed_entries)} entries).",
11354 print(f
"\n[INFO] Study: {study_id}")
11355 print(f
"[INFO] Scanning {len(combinations)} case(s) for completion status...")
11357 incomplete_indices = []
11358 all_case_entries = []
11359 for idx, combo
in enumerate(combinations):
11360 case_id = f
"case_{idx:04d}"
11361 entry = parsed_entries[idx]
11362 entry[
"parameters"] = combo
11363 run_dir = entry[
"run_dir"]
11365 effective_case = copy.deepcopy(base_case)
11366 for full_key, value
in combo.items():
11367 root, nested = full_key.split(
".", 1)
11369 _deep_set(effective_case, nested, value)
11371 eff_start = int(effective_case.get(
"run_control", {}).get(
"start_step", 0)
or 0)
11372 except (TypeError, ValueError):
11374 eff_total = int(effective_case[
"run_control"][
"total_steps"])
11375 target = eff_start + eff_total
11377 monitor_cfg =
read_yaml_file(os.path.join(run_dir,
"config",
"monitor.yml"))
11379 entry[
"_status"] = status
11381 if status[
"status"] ==
"complete":
11382 print(f
" {case_id}: complete (step {status['last_step']}/{target})")
11383 elif status[
"status"] ==
"partial":
11384 print(f
" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
11385 incomplete_indices.append(idx)
11387 print(f
" {case_id}: no checkpoint — will re-run from scratch")
11388 incomplete_indices.append(idx)
11390 all_case_entries.append(entry)
11392 if not incomplete_indices:
11393 print(
"\n[INFO] All cases are complete. Running metrics aggregation...")
11396 print(
"\n" +
"=" * 60)
11397 print(
" STUDY CONTINUATION SUMMARY")
11399 print(f
" Study ID : {study_id}")
11400 print(f
" Status : ALL COMPLETE")
11402 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
11404 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
11408 print(f
"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
11411 for idx
in incomplete_indices:
11412 entry = all_case_entries[idx]
11413 status = entry[
"_status"]
11414 if status[
"status"] ==
"partial":
11416 entry[
"run_dir"], entry[
"case_id"],
11417 status[
"last_step"], status[
"target_step"],
11420 elif status[
"status"] ==
"empty":
11421 print(f
"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
11423 solver_array_spec =
",".join(str(i)
for i
in incomplete_indices)
11424 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
11426 solver_array_spec = f
"{solver_array_spec}%{max_conc}"
11428 max_idx = len(combinations) - 1
11429 post_array_spec = f
"0-{max_idx}"
11431 post_array_spec = f
"{post_array_spec}%{max_conc}"
11436 solver_continue_script = os.path.join(scheduler_dir,
"solver_continue_array.sbatch")
11437 post_continue_script = os.path.join(scheduler_dir,
"post_continue_array.sbatch")
11439 solver_continue_script,
11440 f
"{study_id}_solve_cont",
11445 solver_exe, post_exe,
11446 os.path.join(scheduler_dir,
"solver_cont_%A_%a.out"),
11447 os.path.join(scheduler_dir,
"solver_cont_%A_%a.err"),
11450 post_continue_script,
11451 f
"{study_id}_post_cont",
11456 solver_exe, post_exe,
11457 os.path.join(scheduler_dir,
"post_cont_%A_%a.out"),
11458 os.path.join(scheduler_dir,
"post_cont_%A_%a.err"),
11461 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
11462 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_continue_aggregate.sbatch")
11464 metrics_aggregate_script,
11465 f
"{study_id}_metrics_cont",
11470 print(f
"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
11473 "launch_mode":
"slurm",
11474 "study_id": study_id,
11475 "continuation":
True,
11476 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
11477 "solver_continue_array": {
"script": solver_continue_script,
"submitted":
False},
11478 "post_continue_array": {
"script": post_continue_script,
"submitted":
False},
11479 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
11480 "no_submit": bool(args.no_submit),
11482 if not args.no_submit:
11484 submission[
"solver_continue_array"].update(solver_submit)
11485 submission[
"solver_continue_array"][
"submitted"] =
True
11486 post_submit =
submit_sbatch(post_continue_script, dependency=solver_submit[
"job_id"])
11487 submission[
"post_continue_array"].update(post_submit)
11488 submission[
"post_continue_array"][
"submitted"] =
True
11489 submission[
"post_continue_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
11490 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
11491 submission[
"metrics_aggregate"].update(metrics_submit)
11492 submission[
"metrics_aggregate"][
"submitted"] =
True
11493 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
11494 print(f
"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
11495 print(f
"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
11496 print(f
"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
11498 write_json_file(os.path.join(scheduler_dir,
"submission_continue.json"), submission)
11500 manifest[
"continuation"] = {
11501 "continued_at": datetime.now().isoformat(),
11502 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
11503 "submission": submission,
11507 print(
"\n" +
"=" * 60)
11508 print(
" STUDY CONTINUATION SUMMARY")
11510 print(f
" Study ID : {study_id}")
11511 print(f
" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
11512 print(f
" Solver array spec : {solver_array_spec}")
11513 print(f
" Post array spec : {post_array_spec}")
11514 print(f
" Solver script : {os.path.relpath(solver_continue_script)}")
11515 print(f
" Post script : {os.path.relpath(post_continue_script)}")
11516 print(f
" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
11517 if not args.no_submit:
11518 print(f
" [Metrics aggregation will run automatically after post-processing]")
11520 print(f
" [--no-submit] Scripts generated but not submitted.")
11521 print(f
" After manual submission and completion, run:")
11522 print(f
" picurv sweep --reaggregate --study-dir {os.path.relpath(study_dir)}")
11528 @brief Re-run metrics aggregation and plot generation for an existing study.
11529 @param[in] args Parsed CLI arguments with study_dir.
11531 study_dir = os.path.abspath(args.study_dir)
11532 study_path = os.path.join(study_dir,
"study.yml")
11533 if not os.path.isfile(study_path):
11534 print(f
"[FATAL] Study config not found: {study_path}", file=sys.stderr)
11539 case_index_file = os.path.join(study_dir,
"scheduler",
"case_index.tsv")
11540 if not os.path.isfile(case_index_file):
11541 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
11546 if len(combinations) != len(parsed_entries):
11548 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11549 f
"case_index.tsv ({len(parsed_entries)} entries).",
11555 for idx, combo
in enumerate(combinations):
11556 entry = parsed_entries[idx]
11557 entry[
"parameters"] = combo
11558 case_entries.append(entry)
11560 results_dir = os.path.join(study_dir,
"results")
11564 print(
"\n" +
"=" * 60)
11565 print(
" REAGGREGATION SUMMARY")
11568 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
11570 print(f
" Plots generated : {len(plots)}")
11574_SUMMARY_NUMERIC_RE = re.compile(
r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
11579 @brief Read YAML when present, otherwise return None.
11580 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
11581 @return Value returned by `_read_yaml_if_exists()`.
11583 if not filepath
or not os.path.isfile(filepath):
11586 with open(filepath,
"r", encoding=
"utf-8")
as f:
11587 return yaml.safe_load(f)
11588 except yaml.YAMLError:
11594 @brief Read JSON when present, otherwise return None.
11595 @param[in] filepath Argument passed to `_read_json_if_exists()`.
11596 @return Value returned by `_read_json_if_exists()`.
11598 if not filepath
or not os.path.isfile(filepath):
11600 with open(filepath,
"r", encoding=
"utf-8")
as f:
11601 return json.load(f)
11606 @brief Best-effort integer parsing for summary extraction.
11607 @param[in] value Argument passed to `_parse_int_loose()`.
11608 @return Value returned by `_parse_int_loose()`.
11612 text = str(value).strip()
11619 return int(float(text))
11626 @brief Best-effort float parsing for summary extraction.
11627 @param[in] value Argument passed to `_parse_float_loose()`.
11628 @return Value returned by `_parse_float_loose()`.
11632 text = str(value).strip()
11643 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
11644 @param[in] text Argument passed to `_extract_numeric_tuple()`.
11645 @return Value returned by `_extract_numeric_tuple()`.
11649 return [float(token)
for token
in _SUMMARY_NUMERIC_RE.findall(text)]
11654 @brief Resolve run-local config and artifact paths for summarize.
11655 @param[in] run_dir Argument passed to `_build_summary_context()`.
11656 @return Value returned by `_build_summary_context()`.
11658 run_dir = os.path.abspath(run_dir)
11659 if not os.path.isdir(run_dir):
11661 ERROR_CODE_CFG_FILE_NOT_FOUND,
11664 message=
"Run directory not found.",
11668 config_dir = os.path.join(run_dir,
"config")
11670 "case": os.path.join(config_dir,
"case.yml"),
11671 "solver": os.path.join(config_dir,
"solver.yml"),
11672 "monitor": os.path.join(config_dir,
"monitor.yml"),
11679 io_cfg = monitor_cfg.get(
"io", {})
if isinstance(monitor_cfg, dict)
else {}
11680 io_dirs = io_cfg.get(
"directories", {})
if isinstance(io_cfg, dict)
else {}
11681 log_dir_name = io_dirs.get(
"log",
"logs")
11682 scheduler_dir = os.path.join(run_dir,
"scheduler")
11684 profiling_cfg = {
"mode":
"off",
"functions": [],
"timestep_file":
"Profiling_Timestep_Summary.csv",
"final_summary_enabled":
True}
11688 particle_console_output_freq =
None
11689 particle_log_interval =
None
11692 particle_log_interval = io_cfg.get(
"particle_log_interval")
11694 particle_count_cfg =
None
11696 particle_count_cfg = (
11697 case_cfg.get(
"models", {})
11698 .get(
"physics", {})
11699 .get(
"particles", {})
11704 "run_dir": run_dir,
11705 "config_dir": config_dir,
11706 "log_dir": os.path.join(run_dir, log_dir_name),
11707 "scheduler_dir": scheduler_dir,
11708 "monitor_cfg": monitor_cfg,
11709 "case_cfg": case_cfg,
11710 "solver_cfg": solver_cfg,
11711 "config_paths": config_paths,
11712 "manifest": manifest,
11713 "profiling_cfg": profiling_cfg,
11714 "particle_console_output_freq": particle_console_output_freq,
11715 "particle_log_interval": particle_log_interval,
11716 "particle_count_cfg": particle_count_cfg,
11722 @brief Return one explicitly requested copied config or fail with a structured error.
11723 @param[in] context Summary context returned by `_build_summary_context()`.
11724 @param[in] name Config selector name.
11725 @return Parsed config mapping.
11727 path = context[
"config_paths"][name]
11728 cfg = context.get(f
"{name}_cfg")
11729 if not os.path.isfile(path):
11731 ERROR_CODE_CFG_FILE_NOT_FOUND,
11734 message=f
"Copied run config '{name}.yml' was not found.",
11735 hint=
"Use a staged run directory containing the requested copied config.",
11738 if not isinstance(cfg, dict)
or not cfg:
11740 ERROR_CODE_CFG_INVALID_VALUE,
11743 message=f
"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
11751 @brief Build timestep-independent run metadata for summarize.
11752 @param[in] context Summary context returned by `_build_summary_context()`.
11753 @return Curated run metadata mapping.
11755 manifest = context[
"manifest"]
11757 "run_id": manifest.get(
"run_id", os.path.basename(context[
"run_dir"])),
11758 "run_dir": context[
"run_dir"],
11759 "created_at": manifest.get(
"created_at"),
11760 "launch_mode": manifest.get(
"launch_mode"),
11761 "git_commit": manifest.get(
"git_commit"),
11762 "solver_num_procs": manifest.get(
"solver_num_procs", manifest.get(
"num_procs")),
11763 "post_num_procs": manifest.get(
"post_num_procs"),
11764 "stages_requested": manifest.get(
"stages_requested"),
11765 "stages_completed_or_submitted": manifest.get(
"stages_completed_or_submitted"),
11771 @brief Build compact turbulence and wall-model selections.
11772 @param[in] turbulence_cfg Case turbulence configuration mapping.
11773 @return Curated turbulence and wall-model mapping.
11776 for key
in (
"les",
"rans",
"wall_function"):
11777 value = turbulence_cfg.get(key)
11778 if isinstance(value, dict):
11780 "enabled": value.get(
"enabled",
True),
11781 "model": value.get(
"model"),
11782 **{k: v
for k, v
in value.items()
if k
not in {
"enabled",
"model"}},
11784 elif value
is not None:
11785 result[key] = value
11791 @brief Build a curated case.yml summary with useful derived quantities.
11792 @param[in] context Summary context returned by `_build_summary_context()`.
11793 @return Curated case configuration mapping.
11796 props = cfg.get(
"properties", {})
11797 scaling = props.get(
"scaling", {})
11798 fluid = props.get(
"fluid", {})
11799 run = cfg.get(
"run_control", {})
11800 grid = cfg.get(
"grid", {})
11801 models = cfg.get(
"models", {})
11802 domain = models.get(
"domain", {})
11803 physics = models.get(
"physics", {})
11804 particles = physics.get(
"particles", {})
11805 start = int(run.get(
"start_step", 0))
11806 total = int(run.get(
"total_steps", 0))
11807 dt = float(run.get(
"dt_physical", 0.0))
11808 length_ref = float(scaling.get(
"length_ref"))
11809 velocity_ref = float(scaling.get(
"velocity_ref"))
11810 density = float(fluid.get(
"density"))
11811 viscosity = float(fluid.get(
"viscosity"))
11813 first_block_faces = {row[
"face"]: row
for row
in prepared_bcs[0]}
11815 "i": first_block_faces[
"-Xi"][
"type"] ==
"PERIODIC",
11816 "j": first_block_faces[
"-Eta"][
"type"] ==
"PERIODIC",
11817 "k": first_block_faces[
"-Zeta"][
"type"] ==
"PERIODIC",
11820 for block_idx, block
in enumerate(prepared_bcs):
11823 "block": block_idx,
11825 {
"face": row[
"face"],
"type": row[
"type"],
"handler": row[
"handler"]}
11832 "start_step": start,
11833 "total_steps": total,
11834 "end_step": start + total,
11836 "duration_physical": total * dt,
11837 "dt_nondimensional": dt * velocity_ref / length_ref,
11840 "length_ref": length_ref,
11841 "velocity_ref": velocity_ref,
11842 "density": density,
11843 "viscosity": viscosity,
11844 "reynolds_number": density * velocity_ref * length_ref / viscosity
if viscosity
else None,
11845 "initial_conditions": props.get(
"initial_conditions", {}),
11848 "mode": grid.get(
"mode"),
11850 "programmatic_settings": grid.get(
"programmatic_settings")
if grid.get(
"mode") ==
"programmatic_c" else None,
11851 "source_file": grid.get(
"source_file"),
11854 "blocks": domain.get(
"blocks", 1),
11855 "dimensionality": physics.get(
"dimensionality",
"3D"),
11856 "periodic": periodic_axes,
11859 "fsi": physics.get(
"fsi", {}),
11860 "particles": particles,
11862 "statistics": models.get(
"statistics", {}),
11864 "boundary_conditions": bc_blocks,
11870 @brief Build a curated solver.yml summary with normalized selections.
11871 @param[in] context Summary context returned by `_build_summary_context()`.
11872 @return Curated solver configuration mapping.
11875 strategy = cfg.get(
"strategy", {})
or {}
11877 momentum_cfg = cfg.get(
"momentum_solver", {})
or {}
11878 dualtime = momentum_cfg.get(
"dual_time_picard_jameson_rk", momentum_cfg.get(
"dual_time_picard_rk4", {}))
or {}
11879 newton_krylov = momentum_cfg.get(
"newton_krylov", {})
or {}
11880 poisson = cfg.get(
"poisson_solver", cfg.get(
"pressure_solver", {}))
or {}
11881 convergence = cfg.get(
"solution_convergence", {})
or {}
11883 operation_mode = cfg.get(
"operation_mode", {})
or {}
11888 if operation_mode.get(
"analytical_type")
is not None:
11890 passthrough = cfg.get(
"petsc_passthrough_options", {})
or {}
11892 "operation_mode": operation_mode,
11895 "central_diff": bool(strategy.get(
"central_diff",
False)),
11896 "tolerances": cfg.get(
"tolerances", {}),
11897 "controls": newton_krylov
if selected ==
"newton_krylov" else dualtime,
11899 "poisson": poisson,
11900 "interpolation": cfg.get(
"interpolation", {
"method":
"Trilinear"}),
11901 "solution_convergence": {**convergence,
"mode": convergence_mode},
11902 "scalar_transport": cfg.get(
"scalar_transport", {}),
11903 "verification": cfg.get(
"verification", {}),
11904 "petsc_passthrough": {
"count": len(passthrough),
"options": sorted(passthrough.keys())},
11910 @brief Build a curated monitor.yml summary with resolved defaults.
11911 @param[in] context Summary context returned by `_build_summary_context()`.
11912 @return Curated monitor configuration mapping.
11915 logging_cfg = cfg.get(
"logging", {})
or {}
11916 io_cfg = cfg.get(
"io", {})
or {}
11919 enabled_petsc = sorted(key
for key, value
in diagnostics[
"petsc"].items()
if value
not in (
False,
None))
11922 "verbosity": logging_cfg.get(
"verbosity",
"WARNING"),
11923 "enabled_functions": logging_cfg.get(
"enabled_functions", []),
11927 "enabled_petsc": enabled_petsc,
11928 "petsc": diagnostics[
"petsc"],
11929 "runtime_memory_log": diagnostics[
"runtime_memory_log"],
11932 "data_output_frequency": io_cfg.get(
"data_output_frequency"),
11934 "particle_log_interval": io_cfg.get(
"particle_log_interval"),
11935 "directories": io_cfg.get(
"directories", {}),
11937 "solver_monitoring": {
11938 "enabled_flags": sorted(flag
for flag, value
in monitoring_flags.items()
if value
not in (
False,
None)),
11939 "flags": monitoring_flags,
11946 @brief Parse Continuity_Metrics.log into latest rows by step plus observed order.
11947 @param[in] filepath Argument passed to `_parse_continuity_metrics_log()`.
11948 @return Value returned by `_parse_continuity_metrics_log()`.
11953 if not os.path.isfile(filepath):
11954 return rows_by_step, step_order
11956 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11958 line = raw_line.strip()
11959 if not line
or line.startswith(
"-")
or line.startswith(
"Timestep"):
11961 parts = [part.strip()
for part
in raw_line.split(
"|")]
11971 if step
is None or block
is None:
11973 if step != active_step:
11975 step_order.append(step)
11976 rows_by_step[step] = {}
11977 rows_by_step.setdefault(step, {})[block] = {
11979 "max_divergence": max_div,
11980 "max_divergence_location": parts[3],
11981 "rhs_sum": rhs_sum,
11982 "flux_in": flux_in,
11983 "flux_out": flux_out,
11984 "net_flux": net_flux,
11986 return {step:
list(block_rows.values())
for step, block_rows
in rows_by_step.items()}, step_order
11991 @brief Parse Particle_Metrics.log into latest rows by step plus observed order.
11992 @param[in] filepath Argument passed to `_parse_particle_metrics_log()`.
11993 @return Value returned by `_parse_particle_metrics_log()`.
11997 if not os.path.isfile(filepath):
11998 return rows_by_step, step_order
12000 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
12002 line = raw_line.strip()
12003 if not line
or line.startswith(
"-")
or line.startswith(
"Stage"):
12005 parts = [part.strip()
for part
in raw_line.split(
"|")]
12015 "lost_particles_cumulative":
None,
12016 "migrated_particles":
None,
12017 "occupied_cells":
None,
12018 "load_imbalance":
None,
12019 "migration_passes":
None,
12021 if len(parts) >= 9:
12040 rows_by_step[step] = row
12041 step_order.append(step)
12042 return rows_by_step, step_order
12047 @brief Parse per-block momentum convergence logs.
12048 @param[in] log_dir Argument passed to `_parse_momentum_convergence_logs()`.
12049 @return Value returned by `_parse_momentum_convergence_logs()`.
12055 os.path.join(log_dir,
"Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
12056 os.path.join(log_dir,
"Momentum_Solver_Convergence_History_Block_*.log"),
12060 regex = re.compile(
12061 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\(k\):\s*(?P<pseudo_iter>\d+)\s*\|"
12062 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
12063 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
12064 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
12065 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
12066 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
12067 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
12068 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
12069 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
12075 for path
in sorted(path
for pattern
in patterns
for path
in glob.glob(pattern)):
12076 block_match = re.search(
r"Block_(\d+)\.log$", path)
12077 if not block_match:
12079 block = int(block_match.group(1))
12080 sources[block] = path
12081 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
12083 match = regex.search(raw_line)
12086 step = int(match.group(
"step"))
12087 step_order.append(step)
12088 key = (step, block)
12089 if key
not in _state:
12090 _state[key] = {
"accepted_count": 0,
"rejected_count": 0,
"last_accepted":
None,
"last_rejected":
None}
12091 entry = _state[key]
12092 status = match.group(
"status")
12095 "pseudo_iterations": int(match.group(
"pseudo_iter")),
12100 "delta_norm": float(match.group(
"delta")),
12101 "delta_rel": float(match.group(
"delta_rel")),
12102 "residual_norm": float(match.group(
"resid")),
12103 "residual_rel": float(match.group(
"resid_rel")),
12108 if status ==
"accepted":
12109 entry[
"accepted_count"] += 1
12110 entry[
"last_accepted"] = row
12112 entry[
"rejected_count"] += 1
12113 entry[
"last_rejected"] = row
12115 for (step, block), entry
in _state.items():
12116 display_row = entry[
"last_accepted"]
or entry[
"last_rejected"]
12118 rows_by_step.setdefault(step, {})[block] = {
12120 "accepted_count": entry[
"accepted_count"],
12121 "rejected_count": entry[
"rejected_count"],
12124 newton_pattern = os.path.join(log_dir,
"Momentum_Solver_Newton_Krylov_Summary_Block_*.log")
12125 newton_regex = re.compile(
12126 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
12127 r"\s*solver:\s*(?P<solver>[^|]+?)\s*\|\s*reason:\s*(?P<reason>\S+)\s*\|"
12128 r"\s*reason_code:\s*(?P<reason_code>-?\d+)\s*\|\s*newton:\s*(?P<newton>\d+)\s*\|"
12129 r"\s*evals:\s*(?P<evals>\d+)\s*\|\s*krylov:\s*(?P<krylov>\d+)\s*\|"
12130 r"\s*initial:\s*(?P<initial>[-+0-9.eE]+|unavailable)\s*\|"
12131 r"\s*final:\s*(?P<final>[-+0-9.eE]+)\s*\|\s*state:\s*(?P<state>\w+)"
12133 for path
in sorted(glob.glob(newton_pattern)):
12134 block_match = re.search(
r"Block_(\d+)\.log$", path)
12135 if not block_match:
12137 file_block = int(block_match.group(1))
12138 sources[file_block] = path
12139 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
12141 match = newton_regex.search(raw_line)
12144 step = int(match.group(
"step"))
12145 block = int(match.group(
"block"))
12146 initial_text = match.group(
"initial")
12147 step_order.append(step)
12148 rows_by_step.setdefault(step, {})[block] = {
12150 "solver": match.group(
"solver").strip(),
12151 "reason": match.group(
"reason"),
12152 "reason_code": int(match.group(
"reason_code")),
12153 "newton_iterations": int(match.group(
"newton")),
12154 "residual_evaluations": int(match.group(
"evals")),
12155 "krylov_iterations": int(match.group(
"krylov")),
12156 "initial_norm":
None if initial_text ==
"unavailable" else float(initial_text),
12157 "final_norm": float(match.group(
"final")),
12158 "state": match.group(
"state"),
12161 return rows_by_step, sources, step_order
12166 @brief Parse per-block Poisson convergence logs.
12167 @param[in] log_dir Argument passed to `_parse_poisson_convergence_logs()`.
12168 @return Value returned by `_parse_poisson_convergence_logs()`.
12173 pattern = os.path.join(log_dir,
"Poisson_Solver_Convergence_History_Block_*.log")
12174 regex = re.compile(
12175 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
12176 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
12177 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
12180 for path
in sorted(glob.glob(pattern)):
12181 block_match = re.search(
r"Block_(\d+)\.log$", path)
12182 if not block_match:
12184 block = int(block_match.group(1))
12185 sources[block] = path
12186 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
12188 match = regex.search(raw_line)
12191 step = int(match.group(
"step"))
12192 step_order.append(step)
12193 rows_by_step.setdefault(step, {})[block] = {
12195 "iterations": int(match.group(
"iter")),
12196 "unpreconditioned_norm": float(match.group(
"unpre")),
12197 "true_norm": float(match.group(
"true")),
12200 return rows_by_step, sources, step_order
12205 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
12206 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
12207 @return Value returned by `_parse_profiling_timestep_csv()`.
12212 if not os.path.isfile(filepath):
12213 return rows_by_step, step_order
12215 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
12216 reader = csv.DictReader(f)
12221 if step != active_step:
12223 step_order.append(step)
12224 rows_by_step[step] = []
12225 rows_by_step.setdefault(step, []).append(
12227 "function": row.get(
"function"),
12232 return rows_by_step, step_order
12237 @brief Parse Runtime_Memory.log into latest rows by step and final status.
12238 @param[in] filepath Runtime memory log path.
12239 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
12244 latest_sample_row =
None
12245 max_process_change_mb =
None
12246 if not os.path.isfile(filepath):
12247 return rows_by_step, step_order, {
"available":
False}
12249 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
12251 line = raw_line.strip()
12252 if not line
or line.startswith(
"#")
or line.startswith(
"Step"):
12254 parts = line.split()
12268 "reason": parts[7],
12270 if row[
"process_change_mb_max"]
is not None:
12271 max_process_change_mb = (
12272 row[
"process_change_mb_max"]
12273 if max_process_change_mb
is None
12274 else max(max_process_change_mb, row[
"process_change_mb_max"])
12276 if row[
"event"]
in {
"Step",
"Post"}:
12277 rows_by_step[step] = row
12278 step_order.append(step)
12279 latest_sample_row = row
12280 elif row[
"event"]
in {
"Shutdown",
"Final"}:
12284 "available": bool(rows_by_step
or final_row),
12285 "source": filepath,
12286 "final_event": final_row.get(
"event")
if final_row
else None,
12287 "final_reason": final_row.get(
"reason")
if final_row
else None,
12288 "max_process_change_mb": max_process_change_mb,
12289 "latest_sample_row": latest_sample_row,
12290 "final_row": final_row,
12292 return rows_by_step, step_order, meta
12297 @brief Parse solution_convergence.log into latest rows by step plus observed order.
12299 The log format uses pipe-delimited aligned columns. The first line of the
12300 file is a banner (starts with '=') containing the mode tag; the second line
12301 is the column header; the third line is a separator (starts with '-').
12302 Subsequent lines are one data row per timestep.
12304 @param[in] filepath Path to solution_convergence.log.
12305 @return Mapping of step number to a dict of column values.
12309 if not os.path.isfile(filepath):
12310 return rows_by_step, step_order
12315 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
12317 line = raw_line.strip()
12320 if line.startswith(
"="):
12321 m = re.search(
r"\[mode:\s*([\w_]+)", line)
12325 if line.startswith(
"-"):
12327 if col_names
is None:
12328 col_names = [p.strip()
for p
in raw_line.split(
"|")]
12330 parts = [p.strip()
for p
in raw_line.split(
"|")]
12331 if len(parts) < 4
or col_names
is None:
12336 step_order.append(step)
12337 row = {
"mode": mode}
12338 for name, val
in zip(col_names, parts):
12343 if float_val
is not None and (
"." in val
or "e" in val.lower()):
12344 row[name] = float_val
12345 elif int_val
is not None:
12346 row[name] = int_val
12349 rows_by_step[step] = row
12350 return rows_by_step, step_order
12355 @brief Return plausible solver stream logs for local and Slurm runs.
12356 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
12357 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
12358 @return Value returned by `_find_solver_stream_log_candidates()`.
12361 os.path.join(run_dir,
"scheduler",
"*_solver.log"),
12362 os.path.join(run_dir,
"scheduler",
"solver_*.out"),
12363 os.path.join(log_dir,
"*_solver.log"),
12367 for pattern
in patterns:
12368 for path
in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=
True):
12369 if path
not in seen:
12377 @brief Parse sampled particle snapshots from a solver stream log.
12378 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
12379 @return Value returned by `_parse_particle_snapshot_file()`.
12382 if not os.path.isfile(filepath):
12385 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
12386 lines = f.readlines()
12389 while idx < len(lines):
12390 match = re.search(
r"Particle states at step\s+(\d+):", lines[idx])
12394 step = int(match.group(1))
12397 while idx < len(lines):
12398 stripped = lines[idx].strip()
12399 if re.search(
r"Particle states at step\s+\d+:", lines[idx]):
12401 if stripped.startswith(
"|"):
12402 parts = [part.strip()
for part
in stripped.split(
"|")[1:-1]]
12403 if len(parts) >= 6
and parts[0] !=
"Rank":
12411 "velocity": velocity,
12413 "sample_speed": math.sqrt(sum(component * component
for component
in velocity))
if len(velocity) == 3
else None,
12416 elif rows
and (
not stripped
or stripped.startswith(
"Progress:")):
12420 snapshots[step] = rows
12422 snapshots.setdefault(step, [])
12428 @brief Return the nearest earlier snapshot step when available.
12429 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
12430 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
12431 @return Value returned by `_find_previous_snapshot_step()`.
12433 earlier_steps = [candidate
for candidate
in snapshot_steps
if candidate < step]
12434 if not earlier_steps:
12436 return max(earlier_steps)
12441 @brief Compute sampled deltas between two particle snapshot samples.
12442 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
12443 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
12444 @return Value returned by `_compute_particle_snapshot_delta()`.
12447 previous_by_pid = {
12448 row.get(
"pid"): row
12449 for row
in previous_rows
12450 if row.get(
"pid")
is not None
12453 row.get(
"pid"): row
12454 for row
in current_rows
12455 if row.get(
"pid")
is not None
12457 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
12458 if not matched_pids:
12459 return {
"available":
False}
12462 rank_migrations = 0
12465 for pid
in matched_pids:
12466 current_row = current_by_pid[pid]
12467 previous_row = previous_by_pid[pid]
12468 current_pos = current_row.get(
"position")
or []
12469 previous_pos = previous_row.get(
"position")
or []
12470 if len(current_pos) == len(previous_pos)
and current_pos:
12471 displacements.append(
12474 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
12475 for idx
in range(len(current_pos))
12479 current_speed = current_row.get(
"sample_speed")
12480 previous_speed = previous_row.get(
"sample_speed")
12481 if current_speed
is not None and previous_speed
is not None:
12482 speed_changes.append(float(current_speed) - float(previous_speed))
12483 if current_row.get(
"rank")
is not None and previous_row.get(
"rank")
is not None:
12484 if current_row[
"rank"] != previous_row[
"rank"]:
12485 rank_migrations += 1
12486 if current_row.get(
"cell")
and previous_row.get(
"cell"):
12487 if current_row[
"cell"] != previous_row[
"cell"]:
12492 "matched_pids": len(matched_pids),
12493 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
12494 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
12495 "rank_migrations": rank_migrations,
12496 "cell_changes": cell_changes,
12499 payload[
"mean_displacement"] = float(np.mean(displacements))
12500 payload[
"max_displacement"] = float(np.max(displacements))
12502 payload[
"mean_speed_change"] = float(np.mean(speed_changes))
12503 payload[
"max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
12510 rows:
"list[dict]",
12512 particle_console_output_freq,
12513 particle_log_interval,
12514 previous_step:
"int | None" =
None,
12515 previous_rows:
"list[dict] | None" =
None,
12518 @brief Build sampled diagnostics for one particle console snapshot.
12519 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
12520 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
12521 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
12522 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
12523 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
12524 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
12525 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
12526 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
12527 @return Value returned by `_build_particle_snapshot_summary()`.
12535 "sampled_rows": len(rows),
12536 "preview_rows": rows[:preview_rows],
12538 "particle_console_output_frequency": particle_console_output_freq,
12539 "particle_log_interval": particle_log_interval,
12546 duplicate_pid_count = 0
12547 duplicate_cell_count = 0
12550 zero_weight_count = 0
12551 negative_weight_count = 0
12552 unique_pid_count = 0
12556 position_components = [[], [], []]
12557 weight_components = {}
12561 pid = row.get(
"pid")
12562 if pid
is not None:
12563 if pid
in seen_pids:
12564 duplicate_pid_count += 1
12567 rank = row.get(
"rank")
12568 if rank
is not None:
12569 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
12571 cell = row.get(
"cell")
or []
12574 cell_counter[key] = cell_counter.get(key, 0) + 1
12576 position = row.get(
"position")
or []
12577 for idx, value
in enumerate(position[:3]):
12578 if not np.isfinite(value):
12579 if np.isnan(value):
12584 position_components[idx].append(float(value))
12586 velocity = row.get(
"velocity")
or []
12587 if any(
not np.isfinite(value)
for value
in velocity):
12588 for value
in velocity:
12589 if not np.isfinite(value):
12590 if np.isnan(value):
12594 speed = row.get(
"sample_speed")
12595 if speed
is not None and np.isfinite(speed):
12596 speeds.append(float(speed))
12598 weights = row.get(
"weights")
or []
12599 for idx, value
in enumerate(weights):
12600 if not np.isfinite(value):
12601 if np.isnan(value):
12606 numeric = float(value)
12607 weight_components.setdefault(idx, []).append(numeric)
12608 if abs(numeric) <= 1.0e-15:
12609 zero_weight_count += 1
12611 negative_weight_count += 1
12613 unique_pid_count = len(seen_pids)
12614 duplicate_cell_count = sum(1
for count
in cell_counter.values()
if count > 1)
12616 payload[
"sampled_distribution"] = {
12617 "unique_cells": len(cell_counter),
12618 "duplicate_cells": duplicate_cell_count,
12619 "rank_counts": rank_counts,
12620 "unique_pids": unique_pid_count,
12622 payload[
"checks"] = {
12623 "duplicate_pid_count": duplicate_pid_count,
12624 "nan_count": nan_count,
12625 "inf_count": inf_count,
12626 "zero_weight_count": zero_weight_count,
12627 "negative_weight_count": negative_weight_count,
12631 payload[
"speed"] = {
12632 "min": float(np.min(speeds)),
12633 "mean": float(np.mean(speeds)),
12634 "max": float(np.max(speeds)),
12635 "std": float(np.std(speeds)),
12636 "stagnant_count": sum(1
for speed
in speeds
if abs(speed) < 1.0e-6),
12639 fastest_rows = sorted(
12640 [row
for row
in rows
if row.get(
"sample_speed")
is not None],
12641 key=
lambda row: row[
"sample_speed"],
12644 payload[
"top_speeds"] = [
12646 "pid": row.get(
"pid"),
12647 "rank": row.get(
"rank"),
12648 "speed": row.get(
"sample_speed"),
12649 "cell": row.get(
"cell"),
12651 for row
in fastest_rows
12654 if any(position_components):
12655 axes = [
"x",
"y",
"z"]
12656 payload[
"position_bounds"] = {}
12658 for idx, axis
in enumerate(axes):
12659 values = position_components[idx]
12661 payload[
"position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
12662 centroid.append(float(np.mean(values)))
12664 centroid.append(
None)
12665 payload[
"position_centroid"] = centroid
12667 if weight_components:
12668 payload[
"weights"] = {}
12669 for idx, values
in sorted(weight_components.items()):
12670 payload[
"weights"][f
"component_{idx}"] = {
12671 "min": float(np.min(values)),
12672 "max": float(np.max(values)),
12675 delta_summary = {
"available":
False}
12676 if previous_step
is not None and previous_rows:
12678 if delta_summary.get(
"available"):
12679 delta_summary[
"previous_step"] = previous_step
12680 payload[
"delta_from_previous_snapshot"] = delta_summary
12689 particle_console_output_freq,
12690 particle_log_interval,
12693 @brief Locate and summarize a particle console snapshot for one step.
12694 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
12695 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
12696 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
12697 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
12698 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
12699 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
12700 @return Value returned by `_find_particle_snapshot_for_step()`.
12705 rows = snapshots.get(step)
12708 if best
is None or len(rows) > len(best[
"rows"]):
12709 best = {
"source": path,
"rows": rows,
"snapshots": snapshots}
12712 return {
"available":
False}
12715 previous_rows = best[
"snapshots"].get(previous_step, [])
if previous_step
is not None else None
12721 particle_console_output_freq=particle_console_output_freq,
12722 particle_log_interval=particle_log_interval,
12723 previous_step=previous_step,
12724 previous_rows=previous_rows,
12736 convergence_rows=None,
12738 selection_mode: str =
"latest",
12741 @brief Select a step to summarize from available metric artifacts.
12742 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
12743 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
12744 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
12745 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
12746 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
12747 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
12748 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
12749 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
12750 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
12751 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
12752 @return Value returned by `_resolve_summary_step()`.
12754 if memory_rows
is None:
12756 if convergence_rows
is None:
12757 convergence_rows = {}
12758 if step_orders
is None:
12760 available_steps = (
12761 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
12762 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
12764 if not available_steps:
12767 if requested_step
is not None:
12768 return requested_step, sorted(available_steps)
12770 if selection_mode ==
"max_step":
12771 return max(available_steps), sorted(available_steps)
12773 for order
in step_orders:
12775 return order[-1], sorted(available_steps)
12776 return max(available_steps), sorted(available_steps)
12781 @brief Format optional numeric values for summary text output.
12782 @param[in] value Argument passed to `_format_summary_float()`.
12783 @param[in] spec Argument passed to `_format_summary_float()`.
12784 @param[in] missing Argument passed to `_format_summary_float()`.
12785 @return Value returned by `_format_summary_float()`.
12789 return format(value, spec)
12794 @brief Return the newest modification time among one or more summary sources.
12795 @param[in] paths Path string, iterable of paths, or mapping of paths.
12796 @return Newest modification time, or -1.0 when no source exists.
12798 if isinstance(paths, dict):
12799 paths = paths.values()
12800 elif isinstance(paths, str):
12803 for path
in paths
or []:
12804 if path
and os.path.isfile(path):
12805 newest = max(newest, os.path.getmtime(path))
12811 @brief Order observed step sequences by the recency of their source files.
12812 @param[in] sources Pairs of observed steps and filesystem source path(s).
12813 @return Step-order lists sorted so active append sources are considered first.
12816 for priority, (order, paths)
in enumerate(sources):
12819 ranked.sort(key=
lambda item: (-item[0], item[1]))
12820 return [order
for _, _, order
in ranked]
12825 @brief Build a read-only run-step summary from existing PICurv artifacts.
12826 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
12827 @param[in] step Argument passed to `build_run_summary_payload()`.
12828 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
12829 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
12830 @return Value returned by `build_run_summary_payload()`.
12833 log_dir = context[
"log_dir"]
12834 continuity_path = os.path.join(log_dir,
"Continuity_Metrics.log")
12835 particle_metrics_path = os.path.join(log_dir,
"Particle_Metrics.log")
12842 profiling_rows = {}
12843 profiling_order = []
12844 profiling_path = os.path.join(log_dir, context[
"profiling_cfg"].get(
"timestep_file",
"Profiling_Timestep_Summary.csv"))
12845 if context[
"profiling_cfg"].get(
"mode") !=
"off":
12849 memory_log_file = diagnostics_cfg[
"runtime_memory_log"].get(
"file",
"Runtime_Memory.log")
12850 memory_path = os.path.join(log_dir, memory_log_file)
12853 convergence_log_path = os.path.join(log_dir,
"solution_convergence.log")
12857 (continuity_order, continuity_path),
12858 (particle_order, particle_metrics_path),
12859 (convergence_order, convergence_log_path),
12860 (profiling_order, profiling_path),
12861 (memory_order, memory_path),
12862 (momentum_order, momentum_sources),
12863 (poisson_order, poisson_sources),
12876 step_orders=step_orders,
12877 selection_mode=selection_mode,
12879 if resolved_step
is None:
12881 ERROR_CODE_CFG_FILE_NOT_FOUND,
12884 message=
"No summary-capable run artifacts were found under the run log directory.",
12885 hint=
"Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
12889 if step
is not None and step
not in set(available_steps):
12891 ERROR_CODE_CFG_INVALID_VALUE,
12893 file_path=context[
"run_dir"],
12894 message=f
"Requested step {step} is not present in the available summary artifacts.",
12895 hint=f
"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
12899 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=
lambda row: row[
"block"])
12900 continuity_summary = {
"available": bool(continuity_step_rows),
"blocks": continuity_step_rows}
12901 if continuity_step_rows:
12902 divergence_values = [
12903 abs(row[
"max_divergence"])
12904 for row
in continuity_step_rows
12905 if row[
"max_divergence"]
is not None
12907 continuity_summary[
"max_abs_divergence"] = max(divergence_values)
if divergence_values
else None
12908 continuity_summary[
"net_flux"] = continuity_step_rows[0].get(
"net_flux")
12909 continuity_summary[
"flux_in"] = continuity_step_rows[0].get(
"flux_in")
12910 continuity_summary[
"flux_out"] = continuity_step_rows[0].get(
"flux_out")
12912 momentum_step_rows = [row
for _, row
in sorted(momentum_rows.get(resolved_step, {}).items())]
12913 momentum_summary = {
"available": bool(momentum_step_rows),
"blocks": momentum_step_rows}
12915 poisson_step_rows = [row
for _, row
in sorted(poisson_rows.get(resolved_step, {}).items())]
12916 poisson_summary = {
"available": bool(poisson_step_rows),
"blocks": poisson_step_rows}
12918 particle_summary = {
"available": resolved_step
in particle_rows}
12919 if resolved_step
in particle_rows:
12920 particle_summary.update(particle_rows[resolved_step])
12922 profiling_summary = {
"available": resolved_step
in profiling_rows}
12923 if resolved_step
in profiling_rows:
12924 functions = sorted(
12925 profiling_rows[resolved_step],
12926 key=
lambda row: (row.get(
"step_time_s")
or 0.0),
12929 profiling_summary[
"functions"] = functions
12930 profiling_summary[
"total_logged_step_time_s"] = sum(
12931 row.get(
"step_time_s")
or 0.0
for row
in functions
12934 memory_summary = {
"available": resolved_step
in memory_rows}
12935 if resolved_step
in memory_rows:
12936 memory_summary.update(memory_rows[resolved_step])
12937 memory_summary[
"source"] = memory_path
12938 memory_summary[
"max_process_change_mb"] = memory_meta.get(
"max_process_change_mb")
12939 memory_summary[
"final_event"] = memory_meta.get(
"final_event")
12940 memory_summary[
"final_reason"] = memory_meta.get(
"final_reason")
12941 memory_summary[
"selected_step"] = resolved_step
12942 memory_summary[
"step_match"] =
True
12943 elif memory_meta.get(
"available"):
12944 latest_sample_row = memory_meta.get(
"latest_sample_row")
12945 if latest_sample_row:
12946 memory_summary.update(latest_sample_row)
12947 memory_summary.update(memory_meta)
12948 memory_summary[
"selected_step"] = resolved_step
12949 memory_summary[
"step_match"] =
False
12951 snapshot_summary = {
"available":
False}
12952 if context[
"particle_console_output_freq"]
and context[
"particle_console_output_freq"] > 0:
12954 context[
"run_dir"],
12957 preview_rows=max(1, snapshot_rows),
12958 particle_console_output_freq=context[
"particle_console_output_freq"],
12959 particle_log_interval=context[
"particle_log_interval"],
12963 "profiling_timestep_mode": context[
"profiling_cfg"].get(
"mode"),
12964 "profiling_timestep_file": context[
"profiling_cfg"].get(
"timestep_file"),
12965 "particle_console_output_frequency": context[
"particle_console_output_freq"],
12966 "particle_log_interval": context[
"particle_log_interval"],
12970 "run_id": context[
"manifest"].get(
"run_id", os.path.basename(context[
"run_dir"])),
12971 "run_dir": context[
"run_dir"],
12972 "step": resolved_step,
12973 "selected_via":
"explicit" if step
is not None else (
"max_step" if selection_mode ==
"max_step" else "latest_available"),
12974 "available_steps": available_steps,
12975 "launch_mode": context[
"manifest"].get(
"launch_mode"),
12976 "created_at": context[
"manifest"].get(
"created_at"),
12977 "monitor": monitor_info,
12978 "particles_configured": context[
"particle_count_cfg"],
12980 "continuity_log": continuity_path
if os.path.isfile(continuity_path)
else None,
12981 "particle_metrics_log": particle_metrics_path
if os.path.isfile(particle_metrics_path)
else None,
12982 "momentum_logs": momentum_sources,
12983 "poisson_logs": poisson_sources,
12984 "profiling_timestep_csv": profiling_path
if os.path.isfile(profiling_path)
else None,
12985 "solution_convergence_log": convergence_log_path
if os.path.isfile(convergence_log_path)
else None,
12986 "runtime_memory_log": memory_path
if os.path.isfile(memory_path)
else None,
12988 "continuity": continuity_summary,
12989 "momentum": momentum_summary,
12990 "poisson": poisson_summary,
12991 "particles": particle_summary,
12992 "particle_snapshot": snapshot_summary,
12993 "profiling": profiling_summary,
12994 "memory": memory_summary,
12995 "convergence": convergence_rows.get(resolved_step)
if convergence_rows
else None,
13001 @brief Render a run-step summary in human or JSON form.
13002 @param[in] payload Argument passed to `render_run_summary()`.
13003 @param[in] output_format Argument passed to `render_run_summary()`.
13005 if output_format ==
"json":
13006 print(json.dumps(payload, indent=2, sort_keys=
True))
13009 print(
"\n" +
"=" * 60)
13010 print(
" RUN STEP SUMMARY")
13012 print(f
" Run ID : {payload.get('run_id')}")
13013 print(f
" Run directory : {os.path.relpath(payload.get('run_dir'))}")
13014 print(f
" Step : {payload.get('step')} ({payload.get('selected_via')})")
13015 if payload.get(
"launch_mode"):
13016 print(f
" Launch mode : {payload.get('launch_mode')}")
13017 if payload.get(
"created_at"):
13018 print(f
" Created at : {payload.get('created_at')}")
13020 continuity = payload.get(
"continuity", {})
13021 print(
"\n Continuity:")
13022 if continuity.get(
"available"):
13023 if continuity.get(
"max_abs_divergence")
is not None:
13024 print(f
" max |div| : {continuity['max_abs_divergence']:.6e}")
13025 if continuity.get(
"net_flux")
is not None:
13026 print(f
" net flux : {continuity['net_flux']:.6e}")
13027 for row
in continuity.get(
"blocks", []):
13030 f
"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
13031 f
"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
13034 print(
" unavailable")
13036 momentum = payload.get(
"momentum", {})
13037 print(
"\n Momentum:")
13038 if momentum.get(
"available"):
13039 for row
in momentum.get(
"blocks", []):
13040 if row.get(
"solver") ==
"Newton Krylov":
13041 print(f
" block {row['block']}: solver=Newton Krylov")
13043 f
" newton={row.get('newton_iterations')} "
13044 f
"krylov={row.get('krylov_iterations')} "
13045 f
"evals={row.get('residual_evaluations')}"
13048 f
" final={_format_summary_float(row.get('final_norm'))} "
13049 f
"reason={row.get('reason')} state={row.get('state')}"
13052 status = row.get(
"status")
or "unknown"
13053 accepted = row.get(
"accepted_count")
13054 rejected = row.get(
"rejected_count")
13055 counts_str = f
" accepted={accepted} rejected={rejected}" if accepted
is not None else ""
13057 dtau_val = row.get(
"dtau")
13058 cfl_in = row.get(
"cfl_eff")
13059 cfl_out = row.get(
"cfl_eff_after")
13060 dtau_out = row.get(
"dtau_after")
13061 if cfl_in
is not None and cfl_out
is not None:
13062 cfl_str = f
"cfl_eff {cfl_in:.4f}->{cfl_out:.4f} dtau {_format_summary_float(dtau_val)}->{_format_summary_float(dtau_out)}"
13063 elif cfl_in
is not None:
13064 cfl_str = f
"cfl_eff={_format_summary_float(cfl_in, '.4f')} dtau={_format_summary_float(dtau_val)}"
13066 cfl_str =
"cfl_eff=n/a"
13067 ratio = row.get(
"trial_ratio")
13068 smoothed = row.get(
"smoothed_ratio")
13069 if ratio
is not None and smoothed
is not None:
13070 ratio_str = f
" ratio={_format_summary_float(ratio)} (ema={_format_summary_float(smoothed)})"
13071 elif ratio
is not None:
13072 ratio_str = f
" ratio={_format_summary_float(ratio)}"
13075 print(f
" block {row['block']} [{status}]:{counts_str} {cfl_str}{ratio_str}")
13077 f
" resid={_format_summary_float(row.get('residual_norm'))}"
13078 f
" delta={_format_summary_float(row.get('delta_norm'))}"
13081 print(
" unavailable")
13083 poisson = payload.get(
"poisson", {})
13084 print(
"\n Poisson:")
13085 if poisson.get(
"available"):
13086 for row
in poisson.get(
"blocks", []):
13089 f
"block {row['block']}: iter={row['iterations']} "
13090 f
"true={_format_summary_float(row.get('true_norm'))} "
13091 f
"rel={_format_summary_float(row.get('relative_norm'))}"
13094 print(
" unavailable")
13096 convergence = payload.get(
"convergence")
13097 print(
"\n Solution Convergence:")
13098 if convergence
is not None:
13099 mode = convergence.get(
"mode",
"unknown")
13100 ref = convergence.get(
"ref")
13101 print(f
" mode : {mode} (ref={'yes' if ref else 'no'})")
13102 if mode
in (
"steady_deterministic",
"transient"):
13103 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
13104 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
13105 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
13106 elif mode ==
"periodic_deterministic":
13107 ph = convergence.get(
"ph")
13108 per = convergence.get(
"per")
13109 print(f
" phase : {ph}/{per}")
13110 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
13111 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
13112 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
13113 elif mode ==
"statistical_steady":
13114 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'))}")
13115 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'))}")
13117 print(
" unavailable")
13119 particles = payload.get(
"particles", {})
13120 print(
"\n Particles:")
13121 if particles.get(
"available"):
13122 loss_summary = f
"lost={particles.get('lost_particles')}"
13123 if particles.get(
"lost_particles_cumulative")
is not None:
13125 f
"lost(step/total)={particles.get('lost_particles')}/"
13126 f
"{particles.get('lost_particles_cumulative')}"
13130 f
"total={particles.get('total_particles')} {loss_summary} "
13131 f
"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
13132 f
"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
13135 print(
" unavailable")
13137 memory = payload.get(
"memory", {})
13138 print(
"\n Runtime Memory:")
13139 if memory.get(
"available"):
13140 if memory.get(
"source"):
13141 print(f
" source : {os.path.relpath(memory.get('source'))}")
13142 if memory.get(
"step")
is not None and not memory.get(
"step_match",
True):
13143 print(f
" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
13144 if memory.get(
"event"):
13145 print(f
" event : {memory.get('event')} reason={memory.get('reason', '-')}")
13146 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")
13147 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")
13148 print(f
" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
13149 if memory.get(
"final_reason"):
13150 print(f
" final reason : {memory.get('final_reason')}")
13152 print(
" unavailable")
13154 snapshot = payload.get(
"particle_snapshot", {})
13155 if snapshot.get(
"available"):
13156 print(
"\n Particle Snapshot (sampled):")
13157 print(f
" source : {os.path.relpath(snapshot.get('source'))}")
13158 cadence = snapshot.get(
"cadence", {})
13161 f
"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
13162 f
"row interval {cadence.get('particle_log_interval', 'n/a')}"
13164 print(f
" sampled rows : {snapshot.get('sampled_rows')}")
13165 speed = snapshot.get(
"speed", {})
13169 f
"sampled speeds: min={_format_summary_float(speed.get('min'))} "
13170 f
"mean={_format_summary_float(speed.get('mean'))} "
13171 f
"max={_format_summary_float(speed.get('max'))} "
13172 f
"std={_format_summary_float(speed.get('std'))} "
13173 f
"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
13175 bounds = snapshot.get(
"position_bounds", {})
13176 centroid = snapshot.get(
"position_centroid")
13179 for axis
in (
"x",
"y",
"z"):
13181 bound_parts.append(
13182 f
"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
13184 print(f
" sampled bounds: {' '.join(bound_parts)}")
13188 f
"sampled center: ({_format_summary_float(centroid[0])}, "
13189 f
"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
13191 distribution = snapshot.get(
"sampled_distribution", {})
13195 f
"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
13196 f
"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
13197 f
"unique_pids={distribution.get('unique_pids', 'n/a')} "
13198 f
"ranks={distribution.get('rank_counts', {})}"
13200 weights = snapshot.get(
"weights", {})
13203 for component, summary
in sorted(weights.items()):
13204 weight_parts.append(
13205 f
"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
13207 print(f
" sampled weights: {' '.join(weight_parts)}")
13208 checks = snapshot.get(
"checks", {})
13212 f
"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
13213 f
"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
13214 f
"zero_weight={checks.get('zero_weight_count', 0)} "
13215 f
"negative_weight={checks.get('negative_weight_count', 0)}"
13217 top_speeds = snapshot.get(
"top_speeds", [])
13219 summary =
", ".join(
13220 f
"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
13221 for row
in top_speeds
13223 print(f
" top speeds : {summary}")
13224 delta_summary = snapshot.get(
"delta_from_previous_snapshot", {})
13225 if delta_summary.get(
"available"):
13228 f
"vs prev snap : step={delta_summary.get('previous_step')} "
13229 f
"matched_pids={delta_summary.get('matched_pids')} "
13230 f
"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
13231 f
"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
13232 f
"rank_moves={delta_summary.get('rank_migrations')} "
13233 f
"cell_changes={delta_summary.get('cell_changes')} "
13234 f
"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
13236 print(
" preview rows :")
13237 for row
in snapshot.get(
"preview_rows", []):
13240 f
"pid={row.get('pid')} rank={row.get('rank')} "
13241 f
"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
13244 profiling = payload.get(
"profiling", {})
13245 print(
"\n Profiling:")
13246 if profiling.get(
"available"):
13247 print(f
" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
13248 for row
in profiling.get(
"functions", [])[:5]:
13251 f
"{row.get('function')}: calls={row.get('calls')} "
13252 f
"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
13255 print(
" unavailable")
13259_CONFIG_SUMMARY_WIDTH = 78
13264 @brief Format one configuration-summary value for compact text output.
13265 @param[in] value Value to format.
13266 @return Compact human-readable value.
13270 if isinstance(value, bool):
13271 return "enabled" if value
else "disabled"
13272 if isinstance(value, float):
13273 return f
"{value:.6g}"
13274 if isinstance(value, (list, tuple)):
13276 if isinstance(value, dict):
13279 return ", ".join(f
"{key}={_summary_display_value(item)}" for key, item
in value.items())
13285 @brief Print a strong dashboard-style configuration summary header.
13286 @param[in] title Section title.
13287 @param[in] subtitle Optional one-line section subtitle.
13289 print(
"\n" +
"=" * _CONFIG_SUMMARY_WIDTH)
13290 print(f
"{title:^78}")
13292 print(f
"{subtitle:^78}")
13293 print(
"=" * _CONFIG_SUMMARY_WIDTH)
13298 @brief Print an aligned configuration-summary field group.
13299 @param[in] title Group title.
13300 @param[in] rows Sequence of `(label, value)` pairs.
13302 visible_rows = [(label, value)
for label, value
in rows
if value
is not None]
13303 if not visible_rows:
13305 print(f
"\n {title}")
13306 print(f
" {'-' * (len(title) + 1)}")
13307 for label, value
in visible_rows:
13308 print(f
" {label:<32} {_summary_display_value(value)}")
13313 @brief Flatten nested summary mappings into readable dotted field rows.
13314 @param[in] mapping Mapping to flatten.
13315 @param[in] prefix Optional parent-field prefix.
13316 @return Sequence of `(field, value)` pairs.
13319 for key, value
in mapping.items():
13320 label = f
"{prefix}.{key}" if prefix
else str(key)
13321 if isinstance(value, dict)
and value:
13324 rows.append((label, value))
13330 @brief Render run metadata as a compact dashboard.
13331 @param[in] summary Curated run overview mapping.
13337 (
"Run directory", os.path.relpath(summary.get(
"run_dir"))
if summary.get(
"run_dir")
else None),
13338 (
"Created", summary.get(
"created_at")),
13339 (
"Launch mode", summary.get(
"launch_mode")),
13340 (
"Git commit", summary.get(
"git_commit")),
13346 (
"Solver MPI processes", summary.get(
"solver_num_procs")),
13347 (
"Post MPI processes", summary.get(
"post_num_procs")),
13348 (
"Stages requested", summary.get(
"stages_requested")),
13349 (
"Stages ready/completed", summary.get(
"stages_completed_or_submitted")),
13356 @brief Render the case summary as a glanceable simulation dashboard.
13357 @param[in] summary Curated case configuration mapping.
13359 run = summary.get(
"run_control", {})
13360 props = summary.get(
"properties", {})
13361 grid = summary.get(
"grid", {})
13362 domain = summary.get(
"domain", {})
13363 physics = summary.get(
"physics", {})
13365 f
"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
13366 f
"Re={_summary_display_value(props.get('reynolds_number'))}"
13372 (
"Step range", f
"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
13373 (
"Physical timestep", run.get(
"dt_physical")),
13374 (
"Nondimensional timestep", run.get(
"dt_nondimensional")),
13375 (
"Physical duration", run.get(
"duration_physical")),
13376 (
"Initial conditions", props.get(
"initial_conditions")),
13380 "Fluid And Scaling",
13382 (
"Reynolds number", props.get(
"reynolds_number")),
13383 (
"Reference length", props.get(
"length_ref")),
13384 (
"Reference velocity", props.get(
"velocity_ref")),
13385 (
"Density", props.get(
"density")),
13386 (
"Viscosity", props.get(
"viscosity")),
13392 (
"Grid mode", grid.get(
"mode")),
13393 (
"Blocks", domain.get(
"blocks")),
13394 (
"Dimensionality", domain.get(
"dimensionality")),
13395 (
"Periodic axes", domain.get(
"periodic")),
13396 (
"MPI grid layout", grid.get(
"processor_layout")),
13397 (
"Grid source", grid.get(
"source_file")),
13400 if grid.get(
"programmatic_settings"):
13405 (
"Particles", physics.get(
"particles")),
13406 (
"FSI", physics.get(
"fsi")),
13407 (
"Turbulence", physics.get(
"turbulence")),
13408 (
"Statistics", physics.get(
"statistics")),
13411 boundary_blocks = summary.get(
"boundary_conditions", [])
13412 if boundary_blocks:
13413 print(
"\n Boundary Conditions")
13414 print(
" --------------------")
13415 print(f
" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
13416 print(f
" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
13417 for block
in boundary_blocks:
13418 for face
in block.get(
"faces", []):
13420 f
" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
13421 f
"{face.get('type', '-'):<12} {face.get('handler', '-')}"
13427 @brief Render the solver summary as a glanceable numerical-method dashboard.
13428 @param[in] summary Curated solver configuration mapping.
13430 momentum = summary.get(
"momentum", {})
13431 poisson = summary.get(
"poisson", {})
13432 operation = summary.get(
"operation_mode", {})
13434 f
"Field: {operation.get('eulerian_field_source', '-')} | "
13435 f
"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
13442 (
"Momentum solver", momentum.get(
"type")),
13443 (
"Central differencing", momentum.get(
"central_diff")),
13444 (
"Poisson method", poisson.get(
"method")),
13445 (
"Interpolation", summary.get(
"interpolation")),
13446 (
"Convergence mode", summary.get(
"solution_convergence", {}).get(
"mode")),
13455 passthrough = summary.get(
"petsc_passthrough", {})
13457 "Advanced PETSc Options",
13458 [(
"Option count", passthrough.get(
"count")), (
"Option names", passthrough.get(
"options"))],
13464 @brief Render the monitor summary as a glanceable observability dashboard.
13465 @param[in] summary Curated monitor configuration mapping.
13467 logging_cfg = summary.get(
"logging", {})
13468 profiling = summary.get(
"profiling", {})
13469 diagnostics = summary.get(
"diagnostics", {})
13470 io_cfg = summary.get(
"io", {})
13471 memory_log = diagnostics.get(
"runtime_memory_log", {})
13473 f
"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
13474 f
"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
13480 (
"Verbosity", logging_cfg.get(
"verbosity")),
13481 (
"Enabled functions", logging_cfg.get(
"enabled_functions")),
13488 (
"Field output", io_cfg.get(
"data_output_frequency")),
13489 (
"Particle snapshots", io_cfg.get(
"particle_console_output_frequency")),
13490 (
"Particle row interval", io_cfg.get(
"particle_log_interval")),
13497 (
"Enabled PETSc diagnostics", diagnostics.get(
"enabled_petsc")),
13498 (
"Runtime memory log", memory_log.get(
"enabled")),
13499 (
"Runtime memory file", memory_log.get(
"file")),
13503 solver_monitoring = summary.get(
"solver_monitoring", {})
13505 "Solver Monitoring",
13507 (
"Enabled flags", solver_monitoring.get(
"enabled_flags")),
13508 (
"All flags", solver_monitoring.get(
"flags")),
13515 @brief Render selected timestep-independent config views and optional health.
13516 @param[in] payload Combined selected summary payload.
13517 @param[in] output_format Output format.
13519 if output_format ==
"json":
13520 json_payload = {key: value
for key, value
in payload.items()
if key !=
"_health_requested"}
13521 print(json.dumps(json_payload, indent=2, sort_keys=
True))
13524 if payload.get(
"run_overview")
is not None:
13527 "case": _render_case_summary_text,
13528 "solver": _render_solver_summary_text,
13529 "monitor": _render_monitor_summary_text,
13531 for key
in (
"case",
"solver",
"monitor"):
13532 if key
in payload.get(
"configuration", {}):
13533 renderers[key](payload[
"configuration"][key])
13534 if payload.get(
"_health_requested"):
13535 health_payload = {key: value
for key, value
in payload.items()
if key
not in {
"run_overview",
"configuration",
"_health_requested"}}
13539_SUMMARY_PLOT_LOG_SCALE_FIELDS = {
13540 "delta_norm",
"delta_rel",
"residual_norm",
"residual_rel",
13541 "unpreconditioned_norm",
"true_norm",
"relative_norm",
13542 "u_abs_l2",
"u_rel_l2",
"p_abs_l2",
"p_rel_l2",
13548 @brief Append one numeric append-ordered record for summarize plotting.
13549 @param[out] records Destination record list.
13550 @param[in] source Qualified source prefix.
13551 @param[in] step Logged timestep.
13552 @param[in] line Human-readable line identity.
13553 @param[in] values Candidate field mapping.
13554 @param[in] source_path Source artifact path.
13555 @param[in] segment Zero-based continuation segment within the source artifact.
13559 for key, value
in values.items()
13560 if isinstance(value, (int, float))
and not isinstance(value, bool)
13562 if step
is not None and numeric:
13568 "source_path": source_path,
13569 "segment": int(segment),
13575 @brief Return whether a log line starts a new continuation segment.
13576 @param[in] line Candidate raw or stripped log line.
13577 @return True for the shared continuation marker syntax.
13579 return bool(re.match(
r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
13584 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
13585 @param[in] context Summary context returned by `_build_summary_context()`.
13586 @return Append-ordered plot record list.
13589 log_dir = context[
"log_dir"]
13591 continuity_path = os.path.join(log_dir,
"Continuity_Metrics.log")
13592 if os.path.isfile(continuity_path):
13594 with open(continuity_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13599 parts = [part.strip()
for part
in raw_line.split(
"|")]
13604 records,
"continuity", step, f
"block {block}",
13612 continuity_path, segment,
13615 particle_path = os.path.join(log_dir,
"Particle_Metrics.log")
13616 if os.path.isfile(particle_path):
13618 with open(particle_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13623 parts = [part.strip()
for part
in raw_line.split(
"|")]
13627 offset = 1
if len(parts) >= 9
else 0
13629 records,
"particles", step,
"particles",
13633 "lost_particles_cumulative":
_parse_int_loose(parts[4])
if offset
else None,
13639 particle_path, segment,
13643 momentum_regex = re.compile(
13644 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\(k\):\s*(?P<pseudo_iter>\d+)\s*\|"
13645 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
13646 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
13647 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
13648 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
13649 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
13650 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
13651 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
13652 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
13654 jameson_patterns = [
13655 os.path.join(log_dir,
"Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
13656 os.path.join(log_dir,
"Momentum_Solver_Convergence_History_Block_*.log"),
13658 for path
in sorted(path
for pattern
in jameson_patterns
for path
in glob.glob(pattern)):
13659 block_match = re.search(
r"Block_(\d+)\.log$", path)
13660 if not block_match:
13663 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13668 match = momentum_regex.search(raw_line)
13671 records,
"momentum", int(match.group(
"step")), f
"block {block_match.group(1)}",
13673 "pseudo_iterations": int(match.group(
"pseudo_iter")),
13676 "delta_norm": float(match.group(
"delta")),
13677 "delta_rel": float(match.group(
"delta_rel")),
13678 "residual_norm": float(match.group(
"resid")),
13679 "residual_rel": float(match.group(
"resid_rel")),
13688 newton_history_regex = re.compile(
13689 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
13690 r"\s*newton:\s*(?P<newton>\d+)\s*\|\s*nonlinear_norm:\s*(?P<norm>[-+0-9.eE]+)"
13692 for path
in sorted(glob.glob(os.path.join(log_dir,
"Momentum_Solver_Newton_Krylov_History_Block_*.log"))):
13694 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13699 match = newton_history_regex.search(raw_line)
13702 records,
"momentum", int(match.group(
"step")), f
"block {match.group('block')}",
13704 "newton_iterations": int(match.group(
"newton")),
13705 "residual_norm": float(match.group(
"norm")),
13710 poisson_regex = re.compile(
13711 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
13712 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
13713 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
13715 for path
in sorted(glob.glob(os.path.join(log_dir,
"Poisson_Solver_Convergence_History_Block_*.log"))):
13717 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13722 match = poisson_regex.search(raw_line)
13725 records,
"poisson", int(match.group(
"step")), f
"block {match.group('block')}",
13727 "iterations": int(match.group(
"iter")),
13728 "unpreconditioned_norm": float(match.group(
"unpre")),
13729 "true_norm": float(match.group(
"true")),
13735 profiling_path = os.path.join(log_dir, context[
"profiling_cfg"].get(
"timestep_file",
"Profiling_Timestep_Summary.csv"))
13736 if os.path.isfile(profiling_path):
13739 with open(profiling_path,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
13744 values = next(csv.reader([raw_line]))
13747 if columns
is None:
13750 row = dict(zip(columns, values))
13752 records,
"profiling",
_parse_int_loose(row.get(
"step")), row.get(
"function")
or "unknown",
13754 profiling_path, segment,
13758 memory_path = os.path.join(log_dir, diagnostics[
"runtime_memory_log"].get(
"file",
"Runtime_Memory.log"))
13759 if os.path.isfile(memory_path):
13761 with open(memory_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13766 parts = raw_line.split()
13767 if len(parts) >= 8
and parts[1]
in {
"Step",
"Post"}:
13777 memory_path, segment,
13780 convergence_path = os.path.join(log_dir,
"solution_convergence.log")
13781 if os.path.isfile(convergence_path):
13784 with open(convergence_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13786 line = raw_line.strip()
13790 if not line
or line.startswith((
"=",
"-")):
13792 if columns
is None:
13793 columns = [part.strip()
for part
in raw_line.split(
"|")]
13795 parts = [part.strip()
for part
in raw_line.split(
"|")]
13797 values = {name:
_parse_float_loose(value)
for name, value
in zip(columns, parts)
if name
not in {
"step",
"mode",
"ref"}}
13804 @brief Build available qualified-series metadata from plot records.
13805 @param[in] records Append-ordered plot record list.
13806 @return Available series catalog.
13809 for record
in records:
13810 for field
in record[
"values"]:
13811 name = f
"{record['source']}.{field}"
13812 item = catalog.setdefault(name, {
"series": name,
"lines": {},
"source_paths": set(),
"sample_count": 0})
13813 item[
"lines"][record[
"line"]] = item[
"lines"].get(record[
"line"], 0) + 1
13814 item[
"source_paths"].add(record[
"source_path"])
13815 item[
"sample_count"] += 1
13819 "lines": [{
"label": label,
"sample_count": count}
for label, count
in sorted(item[
"lines"].items())],
13820 "source_paths": sorted(item[
"source_paths"]),
13822 for _, item
in sorted(catalog.items())
13828 @brief Build one normalized plot.gen request from collected summarize records.
13829 @param[in] context Summary context returned by `_build_summary_context()`.
13830 @param[in] records Append-ordered plot record list.
13831 @param[in] series Qualified series name.
13832 @param[in] last_n Optional last-N records per plotted line.
13833 @param[in] linear_y Whether to force linear scaling.
13834 @param[in] output_path Optional explicit output path.
13835 @return Versioned normalized plot request.
13837 source, separator, field = series.partition(
".")
13839 raise ValueError(
"plot series must be qualified as '<source>.<field>'")
13840 matching = [record
for record
in records
if record[
"source"] == source
and field
in record[
"values"]]
13842 raise ValueError(f
"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
13843 latest_segments = {}
13844 for record
in matching:
13845 source_path = record[
"source_path"]
13846 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get(
"segment", 0))
13848 record
for record
in matching
13849 if record.get(
"segment", 0) == latest_segments[record[
"source_path"]]
13852 for record
in matching:
13853 grouped.setdefault(record[
"line"], []).append([record[
"step"], record[
"values"][field]])
13854 if last_n
is not None:
13855 grouped = {label: points[-last_n:]
for label, points
in grouped.items()}
13856 all_values = [point[1]
for points
in grouped.values()
for point
in points]
13857 use_log =
not linear_y
and field
in _SUMMARY_PLOT_LOG_SCALE_FIELDS
and all(value > 0
for value
in all_values)
13858 window_token = f
"last-{last_n}" if last_n
is not None else "full"
13859 safe_series = re.sub(
r"[^A-Za-z0-9_.-]+",
"_", series)
13860 fallback = os.path.join(context[
"run_dir"],
"summary",
"plots", f
"{safe_series}_{window_token}.png")
13862 "schema_version": 1,
13863 "plot_type":
"time_history",
13865 "title": f
"{series} time history",
13866 "x_label":
"Timestep",
13868 "y_scale":
"log" if use_log
else "linear",
13869 "window": {
"mode":
"last" if last_n
is not None else "full",
"last": last_n},
13870 "lines": [{
"label": label,
"points": points}
for label, points
in sorted(grouped.items())],
13871 "output_path": os.path.abspath(output_path)
if output_path
else None,
13872 "fallback_output_path": fallback,
13878 @brief Render available summarize plot-series metadata.
13879 @param[in] catalog Available series catalog.
13880 @param[in] output_format Text or JSON output format.
13882 if output_format ==
"json":
13883 print(json.dumps({
"available_series": catalog}, indent=2, sort_keys=
True))
13885 print(
"\nAVAILABLE TIME-HISTORY SERIES")
13887 for item
in catalog:
13888 labels =
", ".join(line[
"label"]
for line
in item[
"lines"])
13889 print(f
" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
13890 print(f
" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
13895 @brief Invoke standalone plot.gen with one normalized request over stdin.
13896 @param[in] request Versioned normalized plot request.
13898 plotgen_path = os.path.join(GENERATORS_PATH,
"plot.gen")
13899 if not os.path.isfile(plotgen_path):
13900 raise ValueError(f
"plot.gen script not found: {plotgen_path}")
13901 result = subprocess.run(
13902 [sys.executable, plotgen_path,
"--input",
"-"],
13903 input=json.dumps(request),
13905 capture_output=
True,
13909 print(result.stdout.rstrip())
13910 if result.returncode != 0:
13911 details = (result.stderr
or result.stdout
or "unknown plotting error").strip()
13912 if result.returncode == 3:
13914 raise ValueError(f
"plot.gen failed with exit code {result.returncode}: {details}")
13919 @brief Build and render a read-only health summary for a run step.
13920 @param[in] args Command-line style argument list supplied to the function.
13922 if args.step
is not None and args.step < 0:
13924 if args.snapshot_rows < 1:
13926 plot_series = getattr(args,
"plot_series",
None)
13927 list_plot_series = bool(getattr(args,
"list_plot_series",
False))
13928 last_n = getattr(args,
"last_n",
None)
13929 plot_output = getattr(args,
"plot_output",
None)
13930 linear_y = bool(getattr(args,
"linear_y",
False))
13931 plot_mode = bool(plot_series
or list_plot_series)
13932 existing_selectors = any(
13934 getattr(args,
"overview",
False),
13935 getattr(args,
"case",
False),
13936 getattr(args,
"solver",
False),
13937 getattr(args,
"monitor",
False),
13938 args.step
is not None,
13939 getattr(args,
"latest",
False),
13940 getattr(args,
"max_step",
False),
13943 if plot_mode
and existing_selectors:
13944 fail_cli_usage(
"Plot discovery and --plot cannot be combined with config or selected-step selectors.")
13945 if not plot_series
and (last_n
is not None or plot_output
or linear_y):
13946 fail_cli_usage(
"--last, --plot-output, and --linear-y require --plot.")
13947 if last_n
is not None and last_n < 1:
13949 if plot_series
and args.output_format ==
"json":
13950 fail_cli_usage(
"--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
13956 if list_plot_series:
13958 raise ValueError(
"No plottable scalar histories were found in the run logs.")
13964 except PlotDependencyError
as exc:
13966 ERROR_CODE_DEPENDENCY_MISSING,
13968 file_path=sys.executable,
13972 except ValueError
as exc:
13974 ERROR_CODE_CFG_INVALID_VALUE,
13976 file_path=context[
"log_dir"],
13981 selected_configs = {
13983 for name
in (
"case",
"solver",
"monitor")
13984 if bool(getattr(args, name,
False))
13986 if getattr(args,
"overview",
False):
13987 selected_configs.update({
"case",
"solver",
"monitor"})
13988 explicit_health = args.step
is not None or bool(getattr(args,
"latest",
False))
or bool(getattr(args,
"max_step",
False))
13989 health_requested = explicit_health
or (
not selected_configs
and not getattr(args,
"overview",
False))
13993 if selected_configs
or getattr(args,
"overview",
False):
13995 if getattr(args,
"overview",
False):
13997 combined[
"configuration"] = {}
13999 "case": _build_case_overview,
14000 "solver": _build_solver_overview,
14001 "monitor": _build_monitor_overview,
14003 for name
in (
"case",
"solver",
"monitor"):
14004 if name
in selected_configs:
14006 combined[
"configuration"][name] = builders[name](context)
14007 except (KeyError, TypeError, ValueError, ZeroDivisionError)
as exc:
14009 ERROR_CODE_CFG_INVALID_VALUE,
14011 file_path=context[
"config_paths"][name],
14012 message=f
"Could not summarize copied {name}.yml: {exc}",
14016 if not health_requested:
14017 combined[
"_health_requested"] =
False
14021 requested_step = args.step
14022 if requested_step
is None and getattr(args,
"latest",
False):
14023 requested_step =
None
14024 selection_mode =
"max_step" if getattr(args,
"max_step",
False)
else "latest"
14027 step=requested_step,
14028 snapshot_rows=args.snapshot_rows,
14029 selection_mode=selection_mode,
14034 combined = {**health_payload, **combined,
"_health_requested":
True}
14040 @brief Resolve a run/study submission target from explicit directory flags.
14041 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
14042 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
14043 @return Value returned by `_resolve_submission_target()`.
14045 has_run_dir = bool(run_dir)
14046 has_study_dir = bool(study_dir)
14047 if has_run_dir == has_study_dir:
14048 fail_cli_usage(
"submit requires exactly one of --run-dir or --study-dir.")
14050 target_kind =
"run" if has_run_dir
else "study"
14051 target_key =
"run_dir" if target_kind ==
"run" else "study_dir"
14052 root_dir = os.path.abspath(run_dir
if has_run_dir
else study_dir)
14053 if not os.path.isdir(root_dir):
14055 ERROR_CODE_CFG_FILE_NOT_FOUND,
14057 file_path=root_dir,
14058 message=f
"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
14062 scheduler_dir = os.path.join(root_dir,
"scheduler")
14063 submission_path = os.path.join(scheduler_dir,
"submission.json")
14065 if not isinstance(submission_meta, dict):
14067 ERROR_CODE_CFG_FILE_NOT_FOUND,
14068 key=
"scheduler.submission",
14069 file_path=submission_path,
14070 message=
"Target directory does not contain scheduler submission metadata.",
14071 hint=
"Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
14075 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
14076 if launch_mode ==
"local" and target_kind !=
"run":
14078 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14079 key=
"scheduler.launch_mode",
14080 file_path=submission_path,
14081 message=
"Local staged submission is supported for run directories only.",
14082 hint=
"Use --run-dir for local staged execution; study submit remains Slurm-only.",
14085 if launch_mode
not in {
"slurm",
"local"}:
14087 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14088 key=
"scheduler.launch_mode",
14089 file_path=submission_path,
14090 message=f
"Target launch_mode={launch_mode or 'unknown'} is not supported.",
14091 hint=
"Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
14095 if target_kind ==
"run":
14097 "solve": os.path.join(scheduler_dir,
"solver.sbatch"),
14098 "post-process": os.path.join(scheduler_dir,
"post.sbatch"),
14100 display_label =
"Run directory"
14101 manifest_path =
None
14104 "solve": os.path.join(scheduler_dir,
"solver_array.sbatch"),
14105 "post-process": os.path.join(scheduler_dir,
"post_array.sbatch"),
14107 display_label =
"Study directory"
14108 manifest_path = os.path.join(root_dir,
"study_manifest.json")
14111 "target_kind": target_kind,
14112 "target_key": target_key,
14113 "root_dir": root_dir,
14114 "scheduler_dir": scheduler_dir,
14115 "submission_path": submission_path,
14116 "submission_meta": submission_meta,
14117 "launch_mode": launch_mode,
14118 "script_map": script_map,
14119 "display_label": display_label,
14120 "manifest_path": manifest_path,
14126 @brief Return stored metadata for one staged submission target.
14127 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
14128 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
14129 @return Value returned by `_get_submission_stage_metadata()`.
14131 submission_meta = target_context[
"submission_meta"]
14132 if target_context[
"target_kind"] ==
"run":
14133 stages = submission_meta.get(
"stages", {})
14134 if not isinstance(stages, dict):
14136 stage_meta = stages.get(stage_name)
14137 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
14139 key =
"solver_array" if stage_name ==
"solve" else "post_array"
14140 stage_meta = submission_meta.get(key)
14141 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
14146 @brief Return stage names explicitly recorded in scheduler submission metadata.
14147 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
14148 @return Value returned by `_get_recorded_submission_stages()`.
14150 submission_meta = target_context[
"submission_meta"]
14152 if target_context[
"target_kind"] ==
"run":
14153 stages = submission_meta.get(
"stages", {})
14154 if isinstance(stages, dict):
14155 for stage_name
in [
"solve",
"post-process"]:
14156 if isinstance(stages.get(stage_name), dict):
14157 recorded.append(stage_name)
14160 if isinstance(submission_meta.get(
"solver_array"), dict):
14161 recorded.append(
"solve")
14162 if isinstance(submission_meta.get(
"post_array"), dict):
14163 recorded.append(
"post-process")
14169 @brief Format a human-readable stage list for submit diagnostics.
14170 @param[in] stage_names Argument passed to `_format_stage_list()`.
14171 @return Value returned by `_format_stage_list()`.
14173 return ", ".join(stage_names)
if stage_names
else "none"
14178 @brief Build an actionable hint for requested submit stages missing from metadata.
14179 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
14180 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
14181 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
14182 @return Value returned by `_build_submit_missing_stage_hint()`.
14185 recorded_set = set(recorded_stages)
14186 selected_set = set(selected_stages)
14187 target_flag =
"--run-dir" if target_context[
"target_kind"] ==
"run" else "--study-dir"
14188 target_path = os.path.relpath(target_context[
"root_dir"])
14189 submit_prefix = f
"picurv submit {target_flag} {target_path}"
14190 solve_stage_command = (
14191 "picurv run --solve ... --no-submit"
14192 if target_context[
"target_kind"] ==
"run"
14193 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
14195 post_stage_command = (
14196 "picurv run --post-process --post <post.yml> ... --no-submit"
14197 if target_context[
"target_kind"] ==
"run"
14198 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
14200 solve_post_command = (
14201 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
14202 if target_context[
"target_kind"] ==
"run"
14203 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
14206 if requested_stage ==
"all":
14207 if recorded_set == {
"solve"}:
14209 "--stage all requests solve and post-process, but this target records only solve. "
14210 f
"Use `{submit_prefix} --stage solve`, or re-stage with post-processing enabled "
14211 f
"(`{solve_post_command}`)."
14213 if recorded_set == {
"post-process"}:
14215 "--stage all requests solve and post-process, but this target records only post-process. "
14216 f
"Use `{submit_prefix} --stage post-process`, or re-stage including the solve stage "
14217 f
"(`{solve_stage_command}`)."
14219 missing = [stage
for stage
in selected_stages
if stage
not in recorded_set]
14222 "--stage all requests solve and post-process, but submission metadata records "
14223 f
"{_format_stage_list(recorded_stages)}. Re-stage the missing stage(s): "
14224 f
"{_format_stage_list(missing)}."
14227 if selected_set == {
"solve"}
and "solve" not in recorded_set:
14229 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
14230 f
"Re-stage with `{solve_stage_command}`."
14232 if selected_set == {
"post-process"}
and "post-process" not in recorded_set:
14234 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
14235 f
"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
14238 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
14243 @brief Persist one stage's metadata back into the submission payload.
14244 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
14245 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
14246 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
14248 submission_meta = target_context[
"submission_meta"]
14249 if target_context[
"target_kind"] ==
"run":
14250 stages = submission_meta.get(
"stages")
14251 if not isinstance(stages, dict):
14253 submission_meta[
"stages"] = stages
14254 stages[stage_name] = stage_meta
14257 key =
"solver_array" if stage_name ==
"solve" else "post_array"
14258 submission_meta[key] = stage_meta
14263 @brief Write updated submission metadata back to disk.
14264 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
14266 write_json_file(target_context[
"submission_path"], target_context[
"submission_meta"])
14268 manifest_path = target_context.get(
"manifest_path")
14269 if manifest_path
and os.path.isfile(manifest_path):
14271 if isinstance(manifest_payload, dict):
14272 manifest_payload[
"submission"] = target_context[
"submission_meta"]
14278 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
14279 @param[in] args Command-line style argument list supplied to the function.
14282 run_dir=getattr(args,
"run_dir",
None),
14283 study_dir=getattr(args,
"study_dir",
None),
14285 stage_order = [
"solve",
"post-process"]
14286 requested_stage = args.stage
14287 selected_stages = stage_order
if requested_stage ==
"all" else [requested_stage]
14289 print(f
"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
14290 print(f
"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
14291 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
14293 if target_context.get(
"launch_mode") ==
"local":
14299 solve_existing_job_id = str(solve_existing_meta.get(
"job_id",
"")).strip()
14301 for stage_name
in selected_stages:
14303 script_path = target_context[
"script_map"][stage_name]
14305 if not existing_meta:
14307 ERROR_CODE_CFG_MISSING_KEY,
14308 key=f
"scheduler.{stage_name}.metadata",
14309 file_path=target_context[
"submission_path"],
14310 message=f
"Submission metadata does not record stage '{stage_name}'.",
14311 hint=missing_stage_hint,
14315 if not os.path.isfile(script_path):
14317 ERROR_CODE_CFG_FILE_NOT_FOUND,
14318 key=f
"scheduler.{stage_name}.script",
14319 file_path=script_path,
14320 message=f
"Required {stage_name} sbatch artifact is missing.",
14321 hint=missing_stage_hint,
14325 if existing_meta.get(
"submitted")
and not args.force:
14327 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14328 key=f
"scheduler.{stage_name}.submitted",
14329 file_path=target_context[
"submission_path"],
14330 message=f
"Stage '{stage_name}' is already recorded as submitted.",
14331 hint=
"Use --force to resubmit this stage intentionally.",
14336 if stage_name ==
"post-process":
14337 if "solve" in selected_stages:
14338 dependency =
"__NEW_SOLVE_JOB_ID__"
14340 if not (solve_existing_meta.get(
"submitted")
and solve_existing_job_id):
14342 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14343 key=
"scheduler.post-process.dependency",
14344 file_path=target_context[
"submission_path"],
14345 message=
"Post-process submission requires a recorded solve job id when solve is not being submitted in the same command.",
14346 hint=
"Submit --stage solve or --stage all first, or use --force only after solve metadata exists.",
14349 dependency = solve_existing_job_id
14351 stage_plans.append(
14353 "stage": stage_name,
14354 "script": script_path,
14355 "dependency": dependency,
14356 "existing_meta": existing_meta,
14361 for plan
in stage_plans:
14363 dependency = plan[
"dependency"]
14364 if dependency ==
"__NEW_SOLVE_JOB_ID__":
14365 cmd.append(
"--dependency=afterok:<new solve job id>")
14367 cmd.append(f
"--dependency=afterok:{dependency}")
14368 cmd.append(plan[
"script"])
14369 print(f
"[DRY-RUN] Would run: {' '.join(cmd)}")
14370 print(
"[INFO] Dry-run only. No jobs were submitted.")
14373 latest_solve_job_id =
None
14374 for plan
in stage_plans:
14375 dependency = plan[
"dependency"]
14376 if dependency ==
"__NEW_SOLVE_JOB_ID__":
14377 dependency = latest_solve_job_id
14379 submit_info =
submit_sbatch(plan[
"script"], dependency=dependency)
14380 stage_meta = copy.deepcopy(plan[
"existing_meta"])
14381 stage_meta.update(submit_info)
14382 stage_meta[
"script"] = plan[
"script"]
14383 stage_meta[
"submitted"] =
True
14385 stage_meta[
"dependency"] = f
"afterok:{dependency}"
14387 stage_meta.pop(
"dependency",
None)
14390 print(f
"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
14392 if plan[
"stage"] ==
"solve":
14393 latest_solve_job_id = submit_info[
"job_id"]
14400 @brief Execute previously staged local run commands from scheduler/submission.json.
14401 @param[in] args Command-line style argument list supplied to the function.
14402 @param[in] target_context Resolved submission target context.
14403 @param[in] selected_stages Ordered stage names selected by the user.
14405 if target_context[
"target_kind"] !=
"run":
14407 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14408 key=
"scheduler.launch_mode",
14409 file_path=target_context[
"submission_path"],
14410 message=
"Local staged execution is supported for run directories only.",
14411 hint=
"Use --run-dir for local staged execution.",
14417 solve_already_done = bool(solve_existing_meta.get(
"submitted")
or solve_existing_meta.get(
"executed"))
14419 for stage_name
in selected_stages:
14421 command = existing_meta.get(
"command")
14422 if not isinstance(command, list)
or not command:
14424 hint =
"Re-stage the run with picurv run --no-submit before calling picurv submit."
14428 ERROR_CODE_CFG_MISSING_KEY,
14429 key=f
"scheduler.{stage_name}.command",
14430 file_path=target_context[
"submission_path"],
14431 message=f
"Required local command metadata for stage '{stage_name}' is missing.",
14436 if existing_meta.get(
"submitted")
and not args.force:
14438 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14439 key=f
"scheduler.{stage_name}.submitted",
14440 file_path=target_context[
"submission_path"],
14441 message=f
"Stage '{stage_name}' is already recorded as submitted.",
14442 hint=
"Use --force to execute this stage again intentionally.",
14446 if stage_name ==
"post-process" and "solve" not in selected_stages
and not args.force
and not solve_already_done:
14448 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14449 key=
"scheduler.post-process.dependency",
14450 file_path=target_context[
"submission_path"],
14451 message=
"Post-process local execution requires a recorded completed solve stage when solve is not being executed in the same command.",
14452 hint=
"Submit --stage solve or --stage all first, or use --force after confirming source data exists.",
14456 log_file = existing_meta.get(
"log_file")
14457 if not isinstance(log_file, str)
or not log_file.strip():
14458 log_file = os.path.join(
"scheduler", f
"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
14460 stage_plans.append(
14462 "stage": stage_name,
14463 "command": [str(token)
for token
in command],
14464 "log_file": log_file,
14465 "existing_meta": existing_meta,
14470 for plan
in stage_plans:
14471 print(f
"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
14472 print(f
"[DRY-RUN] Log file : {plan['log_file']}")
14473 print(
"[INFO] Dry-run only. No local commands were executed.")
14477 monitor_path = os.path.join(target_context[
"root_dir"],
"config",
"monitor.yml")
14478 if os.path.isfile(monitor_path):
14481 for plan
in stage_plans:
14482 execute_command(plan[
"command"], target_context[
"root_dir"], plan[
"log_file"], monitor_cfg)
14483 stage_meta = copy.deepcopy(plan[
"existing_meta"])
14484 stage_meta[
"command"] = plan[
"command"]
14486 stage_meta[
"log_file"] = plan[
"log_file"]
14487 stage_meta[
"submitted"] =
True
14488 stage_meta[
"executed"] =
True
14489 stage_meta[
"completed_at"] = datetime.now().isoformat()
14491 print(f
"[SUCCESS] Executed local {plan['stage']} stage.")
14498 @brief Cancel Slurm-submitted jobs for an existing run directory.
14499 @param[in] args Command-line style argument list supplied to the function.
14501 run_dir = os.path.abspath(args.run_dir)
14502 if not os.path.isdir(run_dir):
14504 ERROR_CODE_CFG_FILE_NOT_FOUND,
14507 message=
"Run directory not found.",
14511 submission_path = os.path.join(run_dir,
"scheduler",
"submission.json")
14513 if not isinstance(submission_meta, dict):
14515 ERROR_CODE_CFG_FILE_NOT_FOUND,
14516 key=
"scheduler.submission",
14517 file_path=submission_path,
14518 message=
"Run directory does not contain scheduler submission metadata.",
14519 hint=
"Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
14523 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
14524 if launch_mode !=
"slurm":
14526 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14527 key=
"scheduler.launch_mode",
14528 file_path=submission_path,
14529 message=f
"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
14530 hint=
"picurv cancel currently supports Slurm-submitted runs only.",
14534 stage_order = [
"solve",
"post-process"]
14535 requested_stage = args.stage
14536 selected_stages = stage_order
if requested_stage ==
"all" else [requested_stage]
14537 recorded_stages = submission_meta.get(
"stages", {})
14538 if not isinstance(recorded_stages, dict):
14539 recorded_stages = {}
14543 for stage_name
in selected_stages:
14544 stage_meta = recorded_stages.get(stage_name)
14545 if not isinstance(stage_meta, dict):
14546 skipped.append((stage_name,
"no stage metadata recorded"))
14549 job_id = str(stage_meta.get(
"job_id",
"")).strip()
14550 if not stage_meta.get(
"submitted"):
14551 skipped.append((stage_name,
"job was generated but not submitted"))
14554 skipped.append((stage_name,
"submitted stage is missing a recorded job id"))
14557 job_to_stages.setdefault(job_id, []).append(stage_name)
14559 if not job_to_stages:
14560 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
14561 print(f
"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14562 for stage_name, reason
in skipped:
14563 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
14564 print(
"[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
14567 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
14568 print(f
"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14569 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
14572 for stage_name, reason
in skipped:
14573 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
14575 graceful = bool(getattr(args,
"graceful",
False))
14577 for job_id, stage_names
in job_to_stages.items():
14578 joined_stage_names =
", ".join(stage_names)
14579 use_graceful_signal = graceful
and "solve" in stage_names
14580 scancel_cmd = [
"scancel"]
14581 if use_graceful_signal:
14582 scancel_cmd.append(
"--signal=USR1")
14583 scancel_cmd.append(job_id)
14586 print(f
"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
14589 result = subprocess.run(scancel_cmd, text=
True, capture_output=
True, check=
False)
14590 stderr_text = (result.stderr
or "").strip()
14591 stdout_text = (result.stdout
or "").strip()
14592 if result.returncode == 0:
14593 if use_graceful_signal:
14595 f
"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
14596 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
14599 print(f
"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
14602 detail = stderr_text
or stdout_text
or "unknown scancel failure"
14603 failures.append((job_id, joined_stage_names, detail, result.returncode))
14605 f
"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
14610 print(
"[INFO] Dry-run only. No jobs were canceled.")
14619 @brief Implements the 'init' command.
14620 @details Creates a new case study directory by copying a template.
14621 Runtime binaries are resolved from the project bin/ directory
14622 via PATH; use 'sync-binaries' to pin specific versions locally.
14623 @param[in] args The command-line arguments parsed by argparse.
14629 except ValueError
as exc:
14630 print(f
"[FATAL] {exc}", file=sys.stderr)
14634 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name
if args.dest_name
else args.template_name))
14636 if os.path.exists(dest_path):
14637 print(f
"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
14640 print(f
"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
14642 shutil.copytree(template_path, dest_path)
14643 print(f
"[SUCCESS] Copied template files to: {dest_path}")
14645 copied_runtime_example = os.path.join(dest_path, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
14646 if os.path.isfile(copied_runtime_example):
14647 os.remove(copied_runtime_example)
14651 print(f
"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14652 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14653 print(
" Seeded from repo-local '.picurv-execution.yml'.")
14654 print(
" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
14655 except Exception
as e:
14656 print(f
"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
14661 source_project_root,
14662 template_name=args.template_name,
14665 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
14668 print(f
"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
14669 except Exception
as e:
14670 print(f
"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
14672 cluster_profile_candidates = sorted(
14674 os.path.basename(path)
14675 for pattern
in (
"*cluster*.yml",
"*cluster*.yaml")
14676 for path
in glob.glob(os.path.join(dest_path, pattern))
14679 if cluster_profile_candidates:
14680 print(
"[INFO] Cluster profile sample(s) copied with this case:")
14681 for profile_name
in cluster_profile_candidates:
14682 print(f
" - {profile_name}")
14683 print(
" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
14685 if getattr(args,
"pin_binaries",
False):
14686 print(
"[INFO] Pinning runtime binaries into case directory...")
14689 for dest_file_path
in copied_binaries:
14690 print(f
" - Pinned '{os.path.basename(dest_file_path)}'")
14691 print(
"[SUCCESS] Case directory is ready with pinned binaries.")
14692 print(
" These local copies will be used instead of bin/ originals.")
14693 except ValueError
as exc:
14694 print(f
"[WARNING] {exc}", file=sys.stderr)
14695 print(
" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
14697 print(
"[SUCCESS] Case directory is ready.")
14698 print(
" Runtime binaries (simulator, postprocessor) are resolved from bin/ automatically.")
14699 print(
" To pin specific binary versions, re-run with --pin-binaries or use: picurv sync-binaries")
14700 print(
" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
14705 @brief Refresh case-local executables from the source repository bin directory.
14706 @param[in] args Command-line style argument list supplied to the function.
14710 case_dir_hint=getattr(args,
"case_dir",
None),
14711 source_root_override=getattr(args,
"source_root",
None),
14718 source_project_root,
14719 template_name=context.get(
"template_name"),
14720 existing=context.get(
"metadata"),
14722 except ValueError
as exc:
14723 print(f
"[FATAL] {exc}", file=sys.stderr)
14726 print(f
"[SUCCESS] Refreshed {len(copied)} binaries in: {case_dir}")
14727 for dest_path
in copied:
14728 print(f
" - {os.path.basename(dest_path)}")
14729 print(f
"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14730 if metadata.get(
"last_known_source_git_commit"):
14731 print(f
"[INFO] Source commit recorded: {metadata['last_known_source_git_commit']}")
14736 @brief Refresh template-managed config/docs files in a case directory.
14737 @param[in] args Command-line style argument list supplied to the function.
14741 case_dir_hint=getattr(args,
"case_dir",
None),
14742 source_root_override=getattr(args,
"source_root",
None),
14743 template_name_override=getattr(args,
"template_name",
None),
14747 template_name = context.get(
"template_name")
14749 existing_managed = context.get(
"metadata", {}).get(
"template_managed_files")
14750 if not isinstance(existing_managed, list):
14751 existing_managed =
None
14755 overwrite=getattr(args,
"overwrite",
False),
14756 prune=getattr(args,
"prune",
False),
14757 managed_rel_paths=existing_managed,
14761 source_project_root,
14762 template_name=template_name,
14763 existing=context.get(
"metadata"),
14764 template_managed_files=summary[
"template_managed_files"],
14767 except ValueError
as exc:
14768 print(f
"[FATAL] {exc}", file=sys.stderr)
14771 print(f
"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
14772 print(f
"[INFO] Copied new files : {len(summary['copied'])}")
14773 print(f
"[INFO] Overwritten files : {len(summary['overwritten'])}")
14774 print(f
"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
14775 print(f
"[INFO] Already unchanged : {len(summary['unchanged'])}")
14776 print(f
"[INFO] Pruned stale files : {len(summary['pruned'])}")
14777 if runtime_result[
"created"]:
14778 print(f
"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14779 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14780 print(
"[INFO] Seed source : repo-local .picurv-execution.yml")
14781 if summary.get(
"prune_requested_without_tracking"):
14782 print(
"[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
14783 print(f
"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14788 @brief Refresh source branches in the repository resolved from a case directory.
14789 @param[in] args Command-line style argument list supplied to the function.
14793 case_dir_hint=getattr(args,
"case_dir",
None),
14794 source_root_override=getattr(args,
"source_root",
None),
14797 except ValueError
as exc:
14798 print(f
"[FATAL] {exc}", file=sys.stderr)
14801 rebase =
not getattr(args,
"no_rebase",
False)
14802 remote = getattr(args,
"remote",
None)
14803 branch = getattr(args,
"branch",
None)
14804 current_branch_only = (
14805 getattr(args,
"current_branch_only",
False)
14806 or remote
is not None
14807 or branch
is not None
14810 if not current_branch_only:
14814 command = [
"git",
"pull"]
14816 command.append(
"--rebase")
14818 command.append(remote)
14820 command.append(branch)
14822 command.extend([
"origin", branch])
14828 @brief Implements the 'build' command.
14829 @details Executes the top-level Makefile directly, passing through any
14830 additional arguments to `make`. This allows for building,
14831 cleaning, and other Makefile targets via the orchestrator
14832 without maintaining a separate build wrapper script.
14833 @param[in] args The command-line arguments parsed by argparse.
14836 print(
"\n" +
"="*27 +
" BUILD STAGE " +
"="*27)
14839 case_dir_hint=getattr(args,
"case_dir",
None),
14840 source_root_override=getattr(args,
"source_root",
None),
14843 except ValueError
as exc:
14844 print(f
"[FATAL] {exc}", file=sys.stderr)
14847 makefile_path = os.path.join(source_project_root,
"Makefile")
14849 if not os.path.isfile(makefile_path):
14850 print(f
"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
14851 print(
" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
14854 make_args =
list(args.make_args
or [])
14856 command = [
"make"] + make_args
14858 command = [
"make",
"all"] + make_args
14859 print(
"[INFO] No explicit make target supplied; defaulting to 'all'.")
14860 print(
" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
Raised when an external command exits unsuccessfully.
__init__(self, list command, int returncode, str details=None)
Initialize a command execution error.
Raised when plot.gen reports a missing optional dependency.
Module-like proxy that preserves picurv.np without eager import.
__getattr__(self, name)
Resolve a NumPy attribute on first use.
summarize_workflow(args)
Build and render a read-only health summary for a run step.
str get_monitor_output_directory(dict monitor_cfg, str default="output")
Resolve the solver output root from monitor.yml, preserving the default layout.
dict _summarize_turbulence(dict turbulence_cfg)
Build compact turbulence and wall-model selections.
dict translate_programmatic_grid_settings(dict grid_settings)
Return programmatic-grid settings translated to the C node-count contract.
find_runtime_execution_config_file(*anchors)
Find the nearest optional execution config from runtime/case anchors.
int normalize_particle_init_mode(str value)
Maps canonical particle init mode names to C enum/int codes (-pinit).
_write_submission_target_metadata(dict target_context)
Write updated submission metadata back to disk.
status_source_command(args)
Report source/case drift for an initialized case directory.
_render_monitor_summary_text(dict summary)
Render the monitor summary as a glanceable observability dashboard.
validate_workflow(args)
Implements picurv validate without launching solver/post workflows.
dict _normalize_square_duct_poiseuille_params(params, str field_name)
Validate square-duct Poiseuille generator parameters.
str _capture_command_stdout(list command, str run_dir)
Run a command, require success, and return stripped stdout text.
list expand_parameter_matrix(dict parameters)
Expand study parameter lists into cartesian-product combinations.
_render_case_summary_text(dict summary)
Render the case summary as a glanceable simulation dashboard.
_parse_float_loose(value)
Best-effort float parsing for summary extraction.
render_run_summary(dict payload, str output_format="text")
Render a run-step summary in human or JSON form.
int normalize_les_model(value)
Maps LES model selectors to C enum/int codes (-les).
bool needs_restart_source(dict case_cfg, dict solver_cfg)
Return True when the solver requires restart data from disk.
str resolve_target_grid_for_generated_profile(dict case_cfg, str case_path, str run_dir)
Resolve an optional target canonical PICGRID for generated profile sampling.
str ensure_post_lock_wrapper(str run_dir)
Ensure the lock wrapper exists for a run directory and return its path.
validate_study_config(dict study_cfg, str study_path, bool skip_base_file_check=False)
Validate sweep/study specification from study.yml.
"tuple[dict, str]" compute_post_recipe_fingerprint(dict recipe_cfg)
Return normalized recipe signature plus SHA-256 fingerprint.
list_template_relative_files(str template_dir, excluded_rel_paths=None)
List all files in a template directory as case-relative paths.
generate_solver_control_file(run_dir, run_id, configs, num_procs, monitor_files, restart_source_dir=None, continue_mode=False)
Generates the main .control file for the C-solver.
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.
dict _build_run_overview(dict context)
Build timestep-independent run metadata for summarize.
_iter_parent_dirs(str start_path)
Yield a path and all of its parents up to filesystem root.
pull_all_source_branches(str run_dir, str log_filename, bool rebase=True)
Refresh every local tracking branch in the source repository, then restore the starting branch.
sync_case_binaries(str case_dir, str source_project_root)
Copy current source-repo binaries into a case directory for version-pinning.
_parse_int_loose(value)
Best-effort integer parsing for summary extraction.
"tuple[dict, list[int]]" _parse_profiling_timestep_csv(str filepath)
Parse profiling timestep CSV into latest rows by step plus observed order.
bool _working_tree_has_tracked_changes(str run_dir)
Return True when the repository has staged or unstaged tracked changes.
list parse_case_index_tsv(str tsv_path)
Parse a case_index.tsv file back into a list of case entry dicts.
str normalize_statistics_task(str task_name)
Normalizes user-facing statistics task names to C pipeline keywords.
bool _post_requests_particle_output(dict post_cfg)
Return whether the current post recipe expects particle VTP output artifacts.
str _resolve_case_relative_path(str path_value, str case_dir)
Resolve a path relative to the current case directory.
str resolve_run_restart_dir(str run_dir, dict monitor_cfg)
Resolve the restart staging directory within a run directory.
extract_metric_from_csv(str case_dir, dict spec)
Extract a scalar metric from a CSV source.
list materialize_generated_prescribed_flow_profiles(str run_dir, dict case_cfg, str case_path, list profile_grid_dims=None)
Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
dict build_walltime_guard_exports("dict | None" cluster_cfg)
Build shell-evaluated environment exports for the runtime walltime guard.
dict detect_case_completion_status(str run_dir, dict monitor_cfg, int target_final_step)
Determine whether a study case is complete, partially complete, or empty.
dict ensure_case_runtime_execution_config(str case_dir, str source_project_root, bool overwrite=False)
Create case-local runtime execution config if missing, seeded from repo-local config when available.
reduce_metric_values(values, str reduction)
Reduce a metric series to one scalar according to the requested reducer.
_set_submission_stage_metadata(dict target_context, str stage_name, dict stage_meta)
Persist one stage's metadata back into the submission payload.
_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.
dict build_run_summary_payload(str run_dir, "int | None" step=None, int snapshot_rows=5, str selection_mode="latest")
Build a read-only run-step summary from existing PICurv artifacts.
str _face_artifact_token(str face)
Convert a BC face token into a filesystem-friendly artifact token.
_attempt_pull_cleanup(str run_dir, bool rebase, log_file)
Best-effort cleanup after a failed git pull so the original branch can be restored.
absolutize_case_external_paths(dict case_cfg, str case_anchor_path)
Convert external grid/generator paths in case config to absolute paths.
list read_picgrid_header_dimensions(str source_grid, int expected_nblk=None)
Read only the canonical PICGRID header dimensions.
dict generate_square_duct_poiseuille_picslice(str output_path, tuple dims, dict params, str target_grid=None, int target_block=0, str target_face=None, str script=None, str case_path=None)
Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
write_json_file(str filepath, dict payload)
Write JSON metadata/manifests with a stable, readable format.
list _build_summary_plot_catalog(list records)
Build available qualified-series metadata from plot records.
str _build_submit_missing_stage_hint(dict target_context, str requested_stage, list selected_stages)
Build an actionable hint for requested submit stages missing from metadata.
execute_command(list command, str run_dir, str log_filename, dict monitor_cfg=None)
Executes a command, streaming its output to the console and a log file.
str write_profile_info(str config_dir, list summaries)
Write a profile.info summary for generated inlet profiles.
compute_case_source_status(str case_dir, str source_project_root, str template_name=None, dict metadata=None)
Compute source/case drift across commits, binaries, and template-managed files.
bool make_args_include_explicit_goal("list[str]" make_args)
Return True when make args contain an explicit target rather than only options/assignments.
_stream_command_to_console_and_log(list command, str run_dir, log_file)
Stream command output to stdout and an already-open log file.
render_selected_summary(dict payload, str output_format="text")
Render selected timestep-independent config views and optional health.
str resolve_post_statistics_output_prefix(dict post_cfg, monitor_cfg=None, str default="Stats")
Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
"list[tuple[str, str | None]]" _get_local_branches_with_upstreams(str run_dir)
Return local branch names plus their configured upstreams.
bool _launcher_arg_contains_whitespace(token)
Return True when a launcher arg token contains embedded whitespace and should be split.
validate_load_mode_step_range(str source_output, int start_step, int total_steps, dict monitor_cfg)
Validate that all required eulerian step files exist for "load" mode.
sweep_reaggregate_workflow(args)
Re-run metrics aggregation and plot generation for an existing study.
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.
str get_post_resume_state_path(str run_dir)
Return the JSON resume metadata path for a run directory.
str normalize_solution_convergence_mode(str value)
Normalizes the solution-convergence mode selector to the C-side canonical string.
dict _build_summary_plot_request(dict context, list records, str series, "int | None" last_n, bool linear_y, "str | None" output_path)
Build one normalized plot.gen request from collected summarize records.
normalize_boundary_conditions_layout(all_blocks_bcs, int num_blocks)
Normalize boundary_conditions to list-of-lists form and validate block count.
str normalize_momentum_solver_type(str value)
Maps canonical user-facing momentum solver names to C-enum CLI values.
_restore_git_head(str run_dir, dict original_head, log_file)
Restore the repository back to the branch or detached commit it started on.
get_post_source_data(dict post_cfg)
Return source_data as a mapping when valid, else an empty mapping.
str _build_post_lock_wrapper_source()
Return the Python wrapper used to hold an exclusive post-stage lock.
float _resolve_field_slice_velocity_scale(dict source, str case_dir)
Resolve field_slice dimensional velocity scale.
None append_grid_da_processor_layout(list control_lines, dict grid_cfg, int num_procs)
Append optional global DMDA layout flags for any grid mode.
dict generate_picgrid_from_programmatic_settings(dict raw_settings, str dest_path, float L_ref)
Generate a canonical PICGRID file from programmatic Cartesian grid settings.
get_post_statistics_output_artifacts(dict post_cfg, str run_dir, monitor_cfg=None)
Predict statistics CSV output paths relative to the postprocessor runtime cwd.
bool _post_requests_eulerian_output(dict post_cfg)
Return whether the current post recipe expects Eulerian VTK output artifacts.
bool is_valid_email(str email)
Lightweight email validation for scheduler notifications.
list build_petsc_diagnostics_args(dict monitor_cfg, str run_dir, str stage_label)
Build PETSc diagnostics command-line arguments for a run stage.
_read_yaml_if_exists(str filepath)
Read YAML when present, otherwise return None.
str resolve_path(str anchor_file, str candidate)
Resolve a potentially relative path against a source YAML file path.
str _post_output_directory_abs(str run_dir, dict post_cfg)
Resolve the absolute post output directory for the current recipe.
tuple _bc_profile_expected_dims(str face, tuple block_dims)
Return expected PICSLICE dimensions for a face and block node dimensions.
_require_successful_command(list command, subprocess.CompletedProcess result)
Raise CommandExecutionError when a captured command failed.
"tuple[dict, list[int], dict]" _parse_runtime_memory_log(str filepath)
Parse Runtime_Memory.log into latest rows by step and final status.
discover_local_project_root(*extra_anchors)
Best-effort source repo discovery from runtime anchors.
_render_run_overview_text(dict summary)
Render run metadata as a compact dashboard.
_diagnostic_resolve_path_or_default(value, str run_dir, str default_filename)
Resolve true/string diagnostics values to a concrete file path.
None add_planned_profile_artifacts(dict plan, dict case_cfg, str run_dir)
Add generated prescribed-flow profile artifacts to a dry-run plan.
"int | None" _find_previous_snapshot_step("list[int]" snapshot_steps, int step)
Return the nearest earlier snapshot step when available.
str _schema_key_hint(dict schema, tuple path, str key, set allowed)
Build a concise typo or hierarchy hint for an unsupported YAML key.
list expand_study_parameter_combinations(dict study_cfg)
Expand either cartesian-study parameters or explicit parameter sets.
None warn_on_grid_generator_hyphen_keys(dict generator, str case_path, list warnings)
Warn when grid.generator uses unsupported hyphenated wrapper keys.
dict submit_sbatch(str script_path, str dependency=None, str dependency_type="afterok")
Submit sbatch script and return submission metadata.
"tuple[dict, dict, list[int]]" _parse_momentum_convergence_logs(str log_dir)
Parse per-block momentum convergence logs.
"int | None" resolve_particle_console_output_frequency(dict io_cfg)
Return the effective particle-console snapshot cadence from monitor.yml.
_extract_numeric_tuple(str text)
Extract a numeric tuple from a string like '(1, 2, 3)'.
validate_and_prepare_boundary_conditions(dict case_cfg)
Validate BC entries against currently supported C-side handlers/types and.
submit_staged_local_run(args, dict target_context, list selected_stages)
Execute previously staged local run commands from scheduler/submission.json.
str _format_stage_list(list stage_names)
Format a human-readable stage list for submit diagnostics.
str convert_legacy_grid_with_gridgen(str case_path, str run_dir, dict grid_cfg, str source_grid)
Optionally convert a legacy file-grid payload to canonical PICGRID using grid.gen.
str run_initial_condition_generator(str case_path, str run_dir, dict resolved_ic)
Run the repository IC generator.
init_case(args)
Implements the 'init' command.
persist_post_resume_state(str run_dir, dict plan, last_successful_requested_end_step=None)
Persist post resume lineage metadata for future –continue runs.
render_metrics_aggregate_script(str script_path, str job_name, dict cluster_cfg, str study_dir, str picurv_path)
Generate a single-node sbatch script that runs metrics aggregation.
dict resolve_ic_cli_params(dict ic, int finit_code, prepared_blocks, float U_ref)
Resolve all IC parameters and return a dict of PETSc option values.
str _format_optional_step(step)
Format an optional step number for user-facing diagnostics.
str resolve_run_output_dir(str run_dir, dict monitor_cfg)
Resolve the output data directory within a run directory.
dict merge_execution_overrides("dict | None" base, "dict | None" override)
Merge execution overrides, letting explicit override values win key-by-key.
optional_matplotlib_pyplot()
Import matplotlib.pyplot lazily for study plot generation.
get_post_input_extensions(dict post_cfg)
Return post input_extensions, preferring io.
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)
Render a Slurm batch script for a single command.
_split_error_file_and_message(str raw_error)
Split '<file>: <message>' style validation strings when possible.
str normalize_wall_function_model(value)
Validates wall-function model selectors exposed in YAML.
str _schema_path_text(tuple path)
Render an internal schema path tuple as a user-facing YAML path.
list _get_recorded_submission_stages(dict target_context)
Return stage names explicitly recorded in scheduler submission metadata.
"tuple[dict, dict, list[int]]" _parse_poisson_convergence_logs(str log_dir)
Parse per-block Poisson convergence logs.
sweep_workflow(args)
Study/sweep orchestration using Slurm job arrays.
str _summary_display_value(value)
Format one configuration-summary value for compact text output.
"tuple[str | None, list[str]]" split_launcher_tokens("str | None" launcher, "list | None" launcher_args=None, str label="launcher")
Canonicalize launcher config into executable token plus argv-style flags.
dict prepare_effective_post_config(dict post_cfg, str resolved_source_dir, int start_step=None, int end_step=None)
Return a copy of post_cfg with resolved source dir and optional effective bounds.
str get_git_commit(str repo_root=None)
Best-effort git commit lookup for run/study manifests and case metadata.
_render_summary_plot_catalog(list catalog, str output_format)
Render available summarize plot-series metadata.
str generate_post_recipe_file(str run_dir, str run_id, dict post_cfg, dict source_files, monitor_cfg=None)
Generates a key=value config file (post.run) for the C post-processor.
render_slurm_array_stage_script(str script_path, str job_name, dict cluster_cfg, str array_spec, str case_index_tsv, str stage, str solver_exe, str post_exe, str stdout_path, str stderr_path)
Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
_mapping_value_with_aliases(dict mapping, *keys, default=None)
Return the first defined value from a mapping across alias keys.
append_turbulence_flags(dict models, list control_lines)
Appends turbulence model flags from legacy or structured case.yml blocks.
"tuple[str, int]" normalize_initial_condition_field(str value)
Normalize a file IC field selector to its staged basename and C enum value.
format_flag_value(value)
Converts Python types to C-style command-line flag values.
"set[int]" _scan_post_vtk_steps(str prefix_path, str extension)
Scan VTK output files matching '<prefix>_<step>.
require_existing_case_dir(str case_dir, str purpose, str source_project_root=None)
Validate that a target case directory exists and is not the source repo root.
dict _build_case_overview(dict context)
Build a curated case.yml summary with useful derived quantities.
bool _to_bool(value, str field_name)
Convert a YAML scalar/string to bool with a clear error message.
submit_staged_jobs(args)
Submit previously staged Slurm artifacts from an existing run/study directory.
str aggregate_study_metrics(dict study_cfg, list cases, str results_dir)
Collect metric values from generated case directories into one CSV.
"tuple[dict, list[int]]" _parse_solution_convergence_log(str filepath)
Parse solution_convergence.log into latest rows by step plus observed order.
dict detect_post_source_frontier(str source_dir, dict monitor_cfg, dict post_cfg, int start_step, int end_step, int step_interval)
Detect the highest contiguous fully available source step for live post-processing.
str resolve_post_source_directory(str run_dir, dict monitor_cfg, dict post_cfg, bool strict=True)
Resolve post source directory token and optionally enforce existence.
dict _build_solver_overview(dict context)
Build a curated solver.yml summary with normalized selections.
sync_case_template_files(str case_dir, str template_dir, bool overwrite=False, bool prune=False, managed_rel_paths=None)
Sync template files into a case directory, preserving modified files unless overwrite is requested.
dict detect_post_completed_frontier(str run_dir, dict post_cfg, monitor_cfg, int start_step, int end_step, int step_interval)
Detect the highest contiguous fully completed post step for the current recipe.
write_yaml_file(str filepath, dict data)
Write YAML with stable ordering for generated study artifacts.
dict _parse_particle_snapshot_file(str filepath)
Parse sampled particle snapshots from a solver stream log.
_read_json_if_exists(str filepath)
Read JSON when present, otherwise return None.
str _resolve_generator_script(str configured_script, str case_path, str default_name)
Resolve an optional generator script override or repository default.
str _command_to_string(list command_tokens)
Render a command list as a shell-safe display string.
float _to_float(value, str field_name)
Convert a YAML scalar to float with a clear error message.
dict _require_summary_config(dict context, str name)
Return one explicitly requested copied config or fail with a structured error.
str parse_slurm_job_id(str sbatch_output)
Extract numeric job id from standard sbatch output.
str resolve_command_log_path(str run_dir, str log_filename)
Resolve a command log filename relative to the run directory.
"tuple[list, dict]" build_post_locked_command(str run_dir, str recipe_fingerprint, list wrapped_command, bool create_wrapper=True)
Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
int normalize_flow_direction_token(str value)
Maps a face-token flow direction string to the C FlowDirection enum integer.
int normalize_rans_model(value)
Maps RANS model selectors to the current C -rans switch.
str resolve_runtime_executable(str executable_name)
Resolve solver/post executable path, preferring local sibling binaries.
_append_summary_plot_record(list records, str source, step, str line, dict values, str source_path, int segment=0)
Append one numeric append-ordered record for summarize plotting.
list _collect_summary_plot_records(dict context)
Collect append-ordered numeric records from summarize-supported scalar logs.
"list[set[int]]" collect_post_completion_families(str run_dir, dict post_cfg, monitor_cfg=None)
Collect per-family completed-step sets for the current post recipe.
list generate_multi_block_bcs(str run_dir, str run_id, dict case_cfg, dict source_files)
Parses multi-block BCs from YAML, generates a .run file for each block, and returns a list of their a...
"tuple[dict, list[int]]" _parse_continuity_metrics_log(str filepath)
Parse Continuity_Metrics.log into latest rows by step plus observed order.
sync_case_config_command(args)
Refresh template-managed config/docs files in a case directory.
build_project(args)
Implements the 'build' command.
int normalize_field_init_mode(str value)
Maps canonical field init mode names to C enum/int codes (-finit).
require_project_root(str candidate, str purpose)
Validate that a source repo root was resolved and is structurally valid.
"list[str]" strip_launcher_size_flags(str launcher_name, "list[str]" launcher_args)
Remove explicit MPI task-count flags from known launchers.
fail_cli_usage(str message, str hint=None)
Emit a structured CLI usage error and exit with code 2.
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.
_drop_imported_package(str package_name)
Remove a failed/partial import package tree from sys.modules.
_nearest_step("set[int]" steps, int target)
Return the complete source step nearest to a target step.
_print_config_header(str title, "str | None" subtitle=None)
Print a strong dashboard-style configuration summary header.
dict stage_initial_condition_file(str run_dir, str case_path, dict resolved_ic)
Materialize and stage one file-backed IC in ReadFieldData's expected layout.
dict validate_and_nondimensionalize_picslice(str source_slice, str dest_slice, float U_ref, tuple expected_dims=None)
Validate a canonical PICSLICE payload and write a solver-scale copy.
str _sanitize_error_field(value)
Normalize error fields into a single-line string.
dict _get_submission_stage_metadata(dict target_context, str stage_name)
Return stored metadata for one staged submission target.
str generate_simple_list_file(str run_dir, str run_id, dict cfg, str section, str key, str filename, dict header_sources)
Generic function to create a file containing a simple list of strings.
dict validate_newton_krylov_config(dict cfg)
Validate and normalize the structured Newton–Krylov solver block.
bool has_explicit_monitor_whitelist(dict monitor_cfg)
Return True when logging.enabled_functions contains at least one entry.
bool is_project_root(str candidate)
Return True when a directory looks like the PICurv source repository root.
list build_local_launch_command(str executable, list executable_args, int num_procs, str config_search_anchor=None, bool allow_single_rank_launcher_override=False, "int | None" force_num_procs=None)
Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
int normalize_les_test_filter(value)
Maps LES test-filter names to the C -testfilter_ik flag.
validate_cluster_config(dict cluster_cfg, str cluster_path)
Validate Slurm scheduler configuration from cluster.yml.
subprocess.CompletedProcess _run_captured_command(list command, str run_dir)
Run a command and capture combined stdout/stderr details for later inspection.
write_case_origin_metadata(str case_dir, str source_project_root, str template_name=None, dict existing=None, template_managed_files=None)
Create or refresh case-origin metadata for repo-aware case maintenance commands.
dict resolve_diagnostics_config(dict monitor_cfg, "str | None" run_dir=None, str stage_label="Solver")
Resolve monitor diagnostics config and default run-local log paths.
list_source_binaries(str source_project_root)
List binary artifacts currently available in the source repo bin directory.
bool _post_needs_particle_source(dict post_cfg)
Return whether the current post recipe requires particle source files to be present.
_print_config_group(str title, list rows)
Print an aligned configuration-summary field group.
resolve_case_origin_context(str case_dir_hint=None, str source_root_override=None, str template_name_override=None)
Resolve case directory, source repo root, and optional template metadata.
_iter_nonempty_noncomment_lines(file_obj)
Yield (lineno, stripped_line) for non-empty, non-comment lines.
str normalize_eulerian_field_source(str value)
Normalizes the Eulerian field source selector to the C-side canonical string.
detect_last_checkpoint_step(str output_dir, str euler_subdir="eulerian", str particle_subdir="particles")
Scan output directory for the highest step number available.
_diagnostic_bool_or_path(value, str key)
Validate a diagnostics value that can be false, true, or a path/viewer string.
dict resolve_profiling_config(dict monitor_cfg)
Resolve profiling reporting config from monitor.yml.
None _validate_yaml_schema_keys(cfg, dict schema, str file_path, list errors, tuple path=())
Reject unsupported YAML keys before they can be silently ignored by staging.
"tuple[int, int, int]" resolve_post_requested_window(dict post_cfg, dict case_cfg=None)
Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
str _classify_error_code(str message)
Map existing validation/error messages to the standardized code set.
sync_case_binaries_command(args)
Refresh case-local executables from the source repository bin directory.
"tuple[str | None, list[str]]" normalize_cluster_launcher(dict execution)
Canonicalize cluster launcher config into executable token plus argv-style flags.
dict _compute_particle_snapshot_delta("list[dict]" current_rows, "list[dict]" previous_rows)
Compute sampled deltas between two particle snapshot samples.
"str | None" resolve_runtime_execution_seed_source(str source_project_root)
Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
validate_solver_configs(dict case_cfg, dict solver_cfg, dict monitor_cfg, str case_path, str solver_path, str monitor_path)
Validates all solver input configs before any work is done.
"list[str]" _find_solver_stream_log_candidates(str run_dir, str log_dir)
Return plausible solver stream logs for local and Slurm runs.
_lookup_allowed_schema_keys(dict schema, tuple path)
Return allowed keys for a path, honoring '*' dynamic mapping entries.
dict _build_particle_snapshot_summary(str source, int step, "list[dict]" rows, int preview_rows, particle_console_output_freq, particle_log_interval, "int | None" previous_step=None, "list[dict] | None" previous_rows=None)
Build sampled diagnostics for one particle console snapshot.
dict _build_monitor_overview(dict context)
Build a curated monitor.yml summary with resolved defaults.
bool _ic_has_inlet(prepared_blocks)
Return True if any prepared BC block contains an INLET face.
dict generate_field_slice_picslice(str output_path, tuple expected_dims, dict source, str target_grid, str target_face, int target_block, str case_path)
Invoke profile.gen to extract a field_slice PICSLICE artifact.
str write_runtime_execution_file(str filepath, str template_source_path=None)
Write a default runtime execution config, copying a source template when available.
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.
validate_post_config(dict post_cfg, str post_path)
Validates the post-processing config before running the post-processor.
dict validate_petsc_vec_binary(str path)
Validate the basic PETSc binary VecView envelope used by ReadFieldData.
str get_post_statistics_output_prefix(dict post_cfg, str default="Stats")
Resolve the statistics CSV prefix, preserving legacy top-level override support.
_print_validation_errors(list errors)
Prints validation errors and exits.
dict build_post_execution_plan(str run_dir, str run_id, dict case_cfg, dict monitor_cfg, dict post_cfg, bool continue_requested=False, bool allow_source_frontier_scan=True)
Resolve post resume/source-availability behavior into one execution plan.
_invoke_plot_gen(dict request)
Invoke standalone plot.gen with one normalized request over stdin.
list get_study_parameter_keys(dict study_cfg)
Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter s...
"tuple[set[int], dict]" _scan_complete_source_steps(str source_dir, dict monitor_cfg, dict post_cfg)
Scan source artifacts and return steps with every file required by the recipe.
dict resolve_grid_da_processor_layout(dict grid_cfg)
Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
str format_command_for_display(list command)
Render a shell-safe command string for console and log output.
_diagnostic_bool_or_all(value, str key)
Validate a diagnostics value that can be false, true, or "all".
"dict | None" resolve_walltime_guard_policy("dict | None" cluster_cfg)
Resolve the effective Slurm walltime-guard policy for generated solver jobs.
auto_identify_run_inputs(str config_dir)
Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
dict _resolve_submission_target(str run_dir=None, str study_dir=None)
Resolve a run/study submission target from explicit directory flags.
bool _post_requests_statistics(dict post_cfg)
Return whether the current post recipe expects statistics CSV artifacts.
sweep_continue_workflow(args)
Continue a partially-completed Slurm parameter sweep study.
bool _is_summary_plot_continuation_marker(str line)
Return whether a log line starts a new continuation segment.
get_post_run_control_value(dict post_cfg, str canonical_key, default=None)
Resolve post run_control values with backwards-compatible legacy aliases.
str generate_header(str run_id, dict source_files)
Creates a standard header block for all generated files.
str normalize_analytical_type(str value)
Normalizes the analytical solution selector to the C-side canonical string.
str _resolve_post_source_directory_preview(str run_dir, dict monitor_cfg, dict post_cfg)
Resolve post source directory without side effects or stdout/stderr output.
dict resolve_initial_condition_config(dict ic, prepared_blocks, float U_ref)
Resolve legacy and structured initial-condition YAML into one launcher contract.
dict prepare_monitor_files(str run_dir, str run_id, dict monitor_cfg, dict source_files)
Generate monitor sidecar files and resolve profiling reporting behavior.
str _resolve_run_artifact_path(str run_dir, str configured_path, str default_path, bool default_to_config_dir=False)
Resolve a run artifact path with run-dir-relative defaults.
dict normalize_post_recipe_signature(dict recipe_cfg)
Normalize post recipe settings into a stable signature mapping.
resolve_restart_source(args, dict case_cfg, dict solver_cfg, dict monitor_cfg, str run_dir)
Resolve the restart source directory based on –restart-from or –continue CLI flags.
"tuple[dict, list[int]]" _parse_particle_metrics_log(str filepath)
Parse Particle_Metrics.log into latest rows by step plus observed order.
"set[int]" _scan_post_statistics_csv_steps(str csv_path)
Scan step ids from the first CSV column of a statistics artifact.
normalize_metric_spec(metric)
Normalize study metric definitions to a common dictionary form.
dict _normalize_prescribed_flow_source(source, str field_name)
Validate the structured source block for prescribed_flow BCs.
get_post_statistics_task_tokens(dict post_cfg)
Return normalized statistics pipeline tokens that will be written into post.run.
infer_plot_x_axis(dict study_cfg, list rows)
Infer x-axis key/values for study plots.
dict get_post_lock_paths(str run_dir)
Return lock-wrapper related paths for a run directory.
populate_restart_directory(str source_output, str target_restart, int start_step, dict monitor_cfg)
Copy checkpoint files for a specific step from source output to target restart.
parse_and_add_model_flags(dict case_cfg, list control_lines)
Parses the 'models' section of case.yml and adds corresponding C-solver flags.
"list[str]" _expected_source_paths_for_step(int step, dict source_scan, dict post_cfg)
Build required source file paths for a single post-processing step.
str _format_summary_float(value, str spec=".6e", str missing="n/a")
Format optional numeric values for summary text output.
find_project_root_upwards(str start_path)
Search upward from an anchor and return the first matching project root.
dict _normalize_field_slice_source(source, str field_name)
Validate a prescribed_flow field_slice source block.
dict resolve_cluster_execution(dict cluster_cfg, str config_search_anchor=None, extra_search_anchors=None)
Resolve cluster execution launcher settings from shared runtime config plus cluster....
generate_study_plots(dict study_cfg, str metrics_csv, str plots_dir)
Generate metric-vs-parameter plots for completed studies.
str get_post_source_directory_template(dict post_cfg, str default="<solver_output_dir>")
Resolve the source directory template from source_data with a safe default.
dict _get_git_head_state(str run_dir)
Capture the current git HEAD branch name and commit hash.
render_run_dry_plan(dict plan, str output_format="text")
Render dry-run plan in human or JSON format.
str run_grid_generator(str case_path, str run_dir, dict grid_cfg)
Runs generators/grid.gen to produce a PICGRID file for this run.
dict build_run_dry_plan(args)
Build a no-write execution plan for run --dry-run.
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.
dict _normalize_field_slice_selector(slice_cfg, str field_name)
Validate the field_slice slice selector.
dict build_serial_post_cluster_config(dict cluster_cfg, int num_procs=1)
Clone cluster config and force a single-node post stage task layout.
_render_solver_summary_text(dict summary)
Render the solver summary as a glanceable numerical-method dashboard.
append_passthrough_flags(list control_lines, dict options)
Appends raw CLI flags to the control list from a {flag: value} dict.
float _summary_source_mtime(paths)
Return the newest modification time among one or more summary sources.
dict build_post_recipe_config(dict post_cfg, monitor_cfg=None)
Build the flat key=value mapping consumed by the C post-processor.
find_case_origin_metadata_file(str case_dir_hint=None)
Find the nearest case-origin metadata file from known runtime anchors.
str resolve_target_grid_for_field_slice(dict case_cfg, str case_path, str run_dir)
Resolve the target canonical PICGRID path needed for field_slice normals.
_prune_incompatible_python_site_paths(paths)
Remove site-package paths for a different Python major/minor version.
_deep_set(dict container, str dotted_path, value)
Set nested dictionary value, creating intermediate maps when needed.
cancel_run_jobs(args)
Cancel Slurm-submitted jobs for an existing run directory.
str normalize_extension(str ext)
Normalize extension.
int normalize_interpolation_method(str value)
Maps interpolation method names to C enum/int codes (-interpolation_method).
None add_planned_grid_artifacts(dict plan, dict case_cfg, str run_dir)
Add grid-mode-specific staged artifacts to a dry-run plan.
bool resolve_enabled_flag(dict cfg, str path, bool default=True)
Resolves a structured enabled flag and rejects non-boolean values.
dict _find_particle_snapshot_for_step(str run_dir, str log_dir, int step, int preview_rows, particle_console_output_freq, particle_log_interval)
Locate and summarize a particle console snapshot for one step.
dict _build_summary_context(str run_dir)
Resolve run-local config and artifact paths for summarize.
extract_metric_from_log(str case_dir, dict spec)
Extract a scalar metric from a log file using regex.
load_case_origin_metadata(str case_dir_hint=None)
Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
str _extract_key_path(str message)
Best-effort key-path extraction from free-form validation messages.
_iter_post_steps(int start_step, int end_step, int step_interval)
Yield configured post-processing steps inclusively.
dict resolve_solver_monitoring_flags(dict monitor_cfg)
Resolve human-readable solver monitoring YAML to raw control flags.
list 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.
str _diagnostic_default_file(str run_dir, str filename)
Return an absolute run-local diagnostics file path.
require_numpy()
Import NumPy only for commands that need numeric reductions.
list _flatten_summary_mapping(dict mapping, str prefix="")
Flatten nested summary mappings into readable dotted field rows.
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.
validate_particle_checkpoint(str source_dir, int start_step, dict monitor_cfg)
Validate that particle checkpoint files exist for the given step.
parse_post_recipe_file(str post_recipe_path)
Parse an existing generated post.run file into a key/value mapping.
"str | None" infer_unique_inlet_axis_from_prepared_bcs(list prepared_blocks)
Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
int parse_slurm_time_limit_to_seconds(str time_text)
Parse a Slurm time-limit string into total seconds.
prepare_case_for_continuation(str run_dir, str case_id, int last_step, int target_final_step, dict cluster_cfg)
Set up a partially-completed study case for continuation in-place.
int get_cluster_total_tasks(dict cluster_cfg)
Return cluster total tasks.
dict read_monitor_from_run(str run_dir)
Read the monitor.yml from a run directory's config/ subdirectory.
"list[list[int]]" _order_summary_step_orders("list[tuple[list[int], object]]" sources)
Order observed step sequences by the recency of their source files.
bool _diagnostic_bool(value, str key)
Validate a diagnostics boolean value.
"tuple[float, float, float]" parse_initial_velocity_components(dict initial_conditions, int finit_code, *bool require_explicit)
Parse initial-condition velocity components with mode-aware defaults.
print_case_source_status(dict status)
Render human-readable source/case drift details.
dict read_yaml_file(str filepath)
Safely reads a YAML file and returns its content.
precompute_workflow(args)
Generate deterministic case artifacts without launching solver/post stages.
dict parse_solver_config(dict solver_cfg)
Parses the structured solver.yml into a flat dictionary of {flag: value}.
run_workflow(args)
Main orchestrator for the 'run' command (local and Slurm modes).
dict resolve_runtime_execution_context(dict runtime_execution_cfg, str context)
Resolve default plus context-specific execution overrides.
pull_source_repo(args)
Refresh source branches in the repository resolved from a case directory.
None validate_programmatic_ic_gen_grid_settings(dict raw_settings)
Validate scalar programmatic grid settings needed to materialize grid.run for ic_gen.
resolve_template_directory(str source_project_root, str template_name)
Resolve an example template directory inside the source repository.
Head of a generic C-style linked list.