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.")
2589 source_run = os.path.abspath(restart_from)
2590 if not os.path.isdir(source_run):
2591 raise ValueError(f
"--restart-from run directory does not exist: {source_run}")
2594 if not os.path.isdir(source_output):
2595 raise ValueError(f
"Source output directory does not exist: {source_output}")
2597 if not requires_source:
2600 "[WARN] --restart-from specified but no data will be read "
2601 "(analytical + init does not need restart data).",
2606 if eulerian_source ==
"load":
2611 return source_output,
False
2618 return target_restart,
False
2622 continue_run_dir = getattr(args,
'run_dir',
None)
2623 if not continue_run_dir:
2624 raise ValueError(
"--continue requires --run-dir.")
2625 continue_run_dir = os.path.abspath(continue_run_dir)
2626 if not os.path.isdir(continue_run_dir):
2627 raise ValueError(f
"--run-dir does not exist: {continue_run_dir}")
2634 if last_step
is not None and last_step != start_step:
2636 f
"[WARN] start_step={start_step} but last checkpoint in output is step {last_step}.",
2640 if eulerian_source ==
"load":
2645 return source_output,
True
2646 elif not requires_source:
2651 euler_needs = (eulerian_source ==
"solve" and start_step > 0)
2653 step_str = f
"{start_step:05d}"
2656 needed_subs.append(euler_sub)
2658 needed_subs.append(particle_sub)
2662 if os.path.isdir(target_restart):
2663 for sub
in needed_subs:
2664 restart_has[sub] = bool(
2665 glob.glob(os.path.join(target_restart, sub, f
"*{step_str}_0.*"))
2668 all_in_restart = needed_subs
and all(restart_has.get(s,
False)
for s
in needed_subs)
2669 some_in_restart = any(restart_has.get(s,
False)
for s
in needed_subs)
2673 print(f
"[INFO] Using curated restart directory: {target_restart}")
2674 return target_restart,
True
2675 elif os.path.isdir(source_output):
2679 missing = [s
for s
in needed_subs
if not restart_has.get(s,
False)]
2680 os.makedirs(target_restart, exist_ok=
True)
2682 src = os.path.join(source_output, sub)
2683 dst = os.path.join(target_restart, sub)
2684 os.makedirs(dst, exist_ok=
True)
2685 if os.path.isdir(src):
2686 for f_name
in glob.glob(os.path.join(src, f
"*{step_str}_0.*")):
2687 shutil.copy2(f_name, dst)
2688 filled = glob.glob(os.path.join(target_restart,
"**", f
"*{step_str}_0.*"), recursive=
True)
2689 print(f
"[INFO] Merged curated restart/ with {len(missing)} component(s) from output/: {target_restart}")
2692 f
"After merge, no checkpoint files found for step {start_step} in {target_restart}"
2694 return target_restart,
True
2698 return target_restart,
True
2701 f
"Neither restart/ nor output/ contain data for step {start_step}. "
2702 f
"Checked: {target_restart}, {source_output}"
2705 elif requires_source:
2707 "Restart data required but no source specified. Use:\n"
2708 " --restart-from <run_dir> (new run from another run's data)\n"
2709 " --continue --run-dir <run_dir> (resume in same directory)"
2716 @brief Convert external grid/generator paths in case config to absolute paths.
2717 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
2718 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
2720 grid_cfg = case_cfg.get(
"grid", {})
2721 if not isinstance(grid_cfg, dict):
2723 mode = grid_cfg.get(
"mode")
2725 source_file = grid_cfg.get(
"source_file")
2726 if isinstance(source_file, str):
2727 grid_cfg[
"source_file"] =
resolve_path(case_anchor_path, source_file)
2728 legacy_cfg = grid_cfg.get(
"legacy_conversion", {})
2729 if isinstance(legacy_cfg, dict):
2730 script_path = legacy_cfg.get(
"script")
2731 if isinstance(script_path, str):
2732 legacy_cfg[
"script"] =
resolve_path(case_anchor_path, script_path)
2733 elif mode ==
"grid_gen":
2734 gen = grid_cfg.get(
"generator", {})
2735 if isinstance(gen, dict):
2736 for key
in (
"script",
"config_file"):
2738 if isinstance(val, str):
2740 ic = (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {})
2741 if isinstance(ic, dict):
2742 if str(ic.get(
"mode",
"")).strip().lower() ==
"file":
2743 source_file = ic.get(
"source_file")
2744 if isinstance(source_file, str):
2745 ic[
"source_file"] =
resolve_path(case_anchor_path, source_file)
2746 elif str(ic.get(
"generator",
"")).strip().lower() ==
"ic_gen":
2747 params = ic.get(
"params", {})
2748 if isinstance(params, dict):
2749 for key
in (
"script",
"config_file"):
2750 value = params.get(key)
2751 if isinstance(value, str):
2753 boundary_conditions = case_cfg.get(
"boundary_conditions", [])
2754 blocks = boundary_conditions
if boundary_conditions
and isinstance(boundary_conditions[0], list)
else [boundary_conditions]
2755 for block
in blocks:
2756 if not isinstance(block, list):
2759 if not isinstance(bc, dict)
or str(bc.get(
"handler",
"")).strip().lower() !=
"prescribed_flow":
2761 source = ((bc.get(
"params")
or {}).get(
"source")
or {})
2762 if not isinstance(source, dict):
2764 source_type = str(source.get(
"type",
"")).strip().lower()
2765 if source_type ==
"file":
2767 elif source_type ==
"generated":
2769 elif source_type ==
"field_slice":
2770 keys = (
"script",
"field_file",
"grid_file",
"source_case")
2774 value = source.get(key)
2775 if isinstance(value, str):
2780 target_final_step: int, cluster_cfg: dict):
2782 @brief Set up a partially-completed study case for continuation in-place.
2783 @details Updates the case config with new start_step/total_steps, sets particle
2784 restart_mode to 'load' if checkpoint exists, populates the restart
2785 directory, and regenerates the solver control file with continue_mode.
2786 Delegates all restart resolution to resolve_restart_source().
2787 @param[in] run_dir Path to the case run directory.
2788 @param[in] case_id The case identifier (e.g. 'case_0002').
2789 @param[in] last_step The last checkpoint step found in the output directory.
2790 @param[in] target_final_step The step number the case should reach for completion.
2791 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
2792 @return The absolute path to the regenerated control file.
2794 config_dir = os.path.join(run_dir,
"config")
2796 solver_cfg =
read_yaml_file(os.path.join(config_dir,
"solver.yml"))
2797 monitor_cfg =
read_yaml_file(os.path.join(config_dir,
"monitor.yml"))
2799 remaining = target_final_step - last_step
2800 case_cfg[
"run_control"][
"start_step"] = last_step
2801 case_cfg[
"run_control"][
"total_steps"] = remaining
2802 print(f
"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
2804 dirs = (monitor_cfg.get(
"io", {})
or {}).get(
"directories", {})
or {}
2805 particle_sub = dirs.get(
"particle_subdir",
"particles")
2807 particle_ckpt = os.path.join(output_dir, particle_sub, f
"position{last_step:05d}_0.dat")
2808 particles_cfg = (case_cfg.get(
"models", {}).get(
"physics", {})
or {}).get(
"particles")
2809 if particles_cfg
is not None and os.path.isfile(particle_ckpt):
2810 current_mode = str(particles_cfg.get(
"restart_mode",
"init")).strip().lower()
2811 if current_mode !=
"load":
2812 particles_cfg[
"restart_mode"] =
"load"
2813 print(f
"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
2817 mock_args = argparse.Namespace(restart_from=
None, continue_run=
True, run_dir=run_dir)
2819 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
2823 'Case': os.path.join(config_dir,
"case.yml"),
2824 'Solver': os.path.join(config_dir,
"solver.yml"),
2825 'Monitor': os.path.join(config_dir,
"monitor.yml"),
2830 "case": case_cfg,
"case_path": source_files[
'Case'],
2831 "solver": solver_cfg,
"solver_path": source_files[
'Solver'],
2832 "monitor": monitor_cfg,
"monitor_path": source_files[
'Monitor'],
2836 run_dir, case_id, configs, cluster_tasks, monitor_files,
2837 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
2839 print(f
"[SUCCESS] {case_id}: regenerated control file for continuation")
2845 @brief Lightweight email validation for scheduler notifications.
2846 @param[in] email Argument passed to `is_valid_email()`.
2847 @return Value returned by `is_valid_email()`.
2849 if not isinstance(email, str):
2851 pattern =
r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
2852 return re.match(pattern, email.strip())
is not None
2856 @brief Normalizes user-facing statistics task names to C pipeline keywords.
2857 @param[in] task_name Task name from YAML.
2858 @return Canonical keyword accepted by C statistics pipeline.
2859 @throws ValueError if task is unsupported.
2862 if task_name
is None:
2863 raise ValueError(
"statistics task cannot be None")
2864 normalized = str(task_name).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
2865 if normalized !=
"msd":
2866 raise ValueError(f
"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
2871 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
2872 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
2874 for lineno, raw
in enumerate(file_obj, start=1):
2876 if not line
or line.startswith(
"#"):
2882 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
2883 @details Requires canonical PICGRID input with leading "PICGRID" token.
2884 Output is always written in canonical PICGRID format with header and per-block dims.
2885 @param[in] source_grid Input grid file path.
2886 @param[in] dest_grid Output grid file path.
2887 @param[in] L_ref Reference length for non-dimensionalization.
2888 @param[in] expected_nblk Optional expected block count.
2889 @return Summary dictionary with nblk, dims, and total_nodes.
2890 @throws ValueError on malformed grid.
2893 raise ValueError(
"length_ref must be non-zero when processing grid coordinates.")
2894 if not os.path.isfile(source_grid):
2895 raise ValueError(f
"Grid file not found: {source_grid}")
2897 with open(source_grid,
"r")
as fin:
2900 _, first_token = next(line_iter)
2901 except StopIteration:
2902 raise ValueError(f
"Grid file '{source_grid}' is empty.")
2904 if first_token !=
"PICGRID":
2906 f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
2909 _, nblk_line = next(line_iter)
2910 except StopIteration:
2911 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
2914 nblk = int(nblk_line)
2916 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
2918 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
2919 if expected_nblk
is not None and nblk != expected_nblk:
2921 f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
2925 for bi
in range(nblk):
2927 lineno, dim_line = next(line_iter)
2928 except StopIteration:
2929 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
2930 parts = dim_line.split()
2933 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
2936 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
2939 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
2941 if im <= 0
or jm <= 0
or km <= 0:
2943 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
2945 dims.append((im, jm, km))
2947 total_nodes_expected = sum(im * jm * km
for (im, jm, km)
in dims)
2948 os.makedirs(os.path.dirname(dest_grid), exist_ok=
True)
2949 with open(dest_grid,
"w")
as fout:
2950 fout.write(
"PICGRID\n")
2951 fout.write(f
"{nblk}\n")
2952 for (im, jm, km)
in dims:
2953 fout.write(f
"{im} {jm} {km}\n")
2955 total_nodes_seen = 0
2956 for lineno, coord_line
in line_iter:
2957 parts = coord_line.split()
2960 f
"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
2963 x = float(parts[0]) / L_ref
2964 y = float(parts[1]) / L_ref
2965 z = float(parts[2]) / L_ref
2968 f
"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
2970 total_nodes_seen += 1
2971 if total_nodes_seen > total_nodes_expected:
2973 f
"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
2975 fout.write(f
"{x:.8e} {y:.8e} {z:.8e}\n")
2977 if total_nodes_seen != total_nodes_expected:
2979 f
"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
2982 return {
"nblk": nblk,
"dims": dims,
"total_nodes": total_nodes_expected}
2986 @brief Read only the canonical PICGRID header dimensions.
2987 @param[in] source_grid Input grid file path.
2988 @param[in] expected_nblk Optional expected block count.
2989 @return List of (IM, JM, KM) node-count tuples.
2990 @throws ValueError on malformed header.
2992 if not os.path.isfile(source_grid):
2993 raise ValueError(f
"Grid file not found: {source_grid}")
2995 with open(source_grid,
"r")
as fin:
2998 _, first_token = next(line_iter)
2999 except StopIteration:
3000 raise ValueError(f
"Grid file '{source_grid}' is empty.")
3001 if first_token !=
"PICGRID":
3002 raise ValueError(f
"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
3005 _, nblk_line = next(line_iter)
3006 nblk = int(nblk_line)
3007 except StopIteration:
3008 raise ValueError(f
"Grid file '{source_grid}' missing block count after PICGRID header.")
3010 raise ValueError(f
"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
3012 raise ValueError(f
"Grid file '{source_grid}' has non-positive block count: {nblk}.")
3013 if expected_nblk
is not None and nblk != expected_nblk:
3014 raise ValueError(f
"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
3017 for bi
in range(nblk):
3019 lineno, dim_line = next(line_iter)
3020 except StopIteration:
3021 raise ValueError(f
"Grid file '{source_grid}' missing dimensions for block {bi}.")
3022 parts = dim_line.split()
3025 f
"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
3028 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
3031 f
"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
3033 if im <= 0
or jm <= 0
or km <= 0:
3035 f
"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
3037 dims.append((im, jm, km))
3042 expected_dims: tuple =
None) -> dict:
3044 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
3045 @param[in] source_slice Input PICSLICE path.
3046 @param[in] dest_slice Output staged PICSLICE path.
3047 @param[in] U_ref Reference velocity for non-dimensionalization.
3048 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
3049 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
3050 @throws ValueError on malformed slice.
3053 raise ValueError(
"velocity_ref must be non-zero when processing PICSLICE speeds.")
3054 if not os.path.isfile(source_slice):
3055 raise ValueError(f
"PICSLICE file not found: {source_slice}")
3057 with open(source_slice,
"r")
as fin:
3060 _, first_token = next(line_iter)
3061 except StopIteration:
3062 raise ValueError(f
"PICSLICE file '{source_slice}' is empty.")
3063 if first_token !=
"PICSLICE":
3064 raise ValueError(f
"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
3067 _, frame_line = next(line_iter)
3068 frame_count = int(frame_line)
3069 except StopIteration:
3070 raise ValueError(f
"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
3072 raise ValueError(f
"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
3073 if frame_count != 1:
3075 f
"PICSLICE file '{source_slice}' has frame count {frame_count}; Phase 1 supports exactly 1."
3079 lineno, dim_line = next(line_iter)
3080 except StopIteration:
3081 raise ValueError(f
"PICSLICE file '{source_slice}' missing slice dimensions.")
3082 parts = dim_line.split()
3085 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
3088 n1, n2 = (int(parts[0]), int(parts[1]))
3091 f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
3093 if n1 <= 0
or n2 <= 0:
3094 raise ValueError(f
"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
3095 if expected_dims
is not None and (n1, n2) != tuple(expected_dims):
3097 f
"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
3101 for lineno, value_line
in line_iter:
3102 parts = value_line.split()
3105 f
"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
3108 value = float(parts[0])
3110 raise ValueError(f
"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
3111 if not math.isfinite(value):
3112 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be finite.")
3114 raise ValueError(f
"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
3115 values.append(value)
3117 expected_count = n1 * n2
3118 if len(values) != expected_count:
3120 f
"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
3123 os.makedirs(os.path.dirname(dest_slice), exist_ok=
True)
3124 with open(dest_slice,
"w")
as fout:
3125 fout.write(
"PICSLICE\n")
3127 fout.write(f
"{n1} {n2}\n")
3128 for value
in values:
3129 fout.write(f
"{value / U_ref:.8e}\n")
3132 "frame_count": frame_count,
3134 "value_count": len(values),
3135 "min_speed": min(values)
if values
else 0.0,
3136 "max_speed": max(values)
if values
else 0.0,
3141 @brief Convert a BC face token into a filesystem-friendly artifact token.
3142 @param[in] face Canonical face token such as -Zeta.
3143 @return Filesystem-friendly face token.
3145 return face.replace(
"+",
"pos").replace(
"-",
"neg")
3148 default_to_config_dir: bool =
False) -> str:
3150 @brief Resolve a run artifact path with run-dir-relative defaults.
3151 @param[in] run_dir Run/precompute directory root.
3152 @param[in] configured_path Optional user-provided artifact path.
3153 @param[in] default_path Default path relative to run_dir.
3154 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
3155 @return Absolute artifact path.
3157 path = configured_path
if configured_path
else default_path
3158 if not isinstance(path, str)
or not path.strip():
3159 raise ValueError(
"generated profile output_file must be a non-empty path when provided.")
3161 if os.path.isabs(path):
3162 return os.path.abspath(path)
3163 if default_to_config_dir
and os.path.dirname(path) ==
"":
3164 path = os.path.join(
"config", path)
3165 return os.path.abspath(os.path.join(run_dir, path))
3169 @brief Resolve an optional generator script override or repository default.
3170 @param[in] configured_script Optional absolute or case-relative script path.
3171 @param[in] case_path Current case.yml path used to anchor relative overrides.
3172 @param[in] default_name Repository generator filename under GENERATORS_PATH.
3173 @return Absolute generator script path.
3175 if configured_script
is None:
3176 return os.path.join(GENERATORS_PATH, default_name)
3177 if not isinstance(configured_script, str)
or not configured_script.strip():
3178 raise ValueError(f
"Generator script override for {default_name} must be a non-empty path.")
3179 script = configured_script.strip()
3180 if os.path.isabs(script):
3181 return os.path.abspath(script)
3182 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3183 return os.path.abspath(os.path.join(case_dir, script))
3187 @brief Validate square-duct Poiseuille generator parameters.
3188 @param[in] params Generator params mapping.
3189 @param[in] field_name Human-readable YAML field name for diagnostics.
3190 @return Normalized params.
3194 if not isinstance(params, dict):
3195 raise ValueError(f
"{field_name}.params must be a mapping when provided.")
3196 unknown = sorted(set(params.keys()) - {
"bulk_velocity",
"n_terms"})
3198 raise ValueError(f
"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
3199 bulk_velocity =
_to_float(params.get(
"bulk_velocity", 1.0), f
"{field_name}.params.bulk_velocity")
3200 if bulk_velocity <= 0.0:
3201 raise ValueError(f
"{field_name}.params.bulk_velocity must be positive.")
3203 n_terms = int(params.get(
"n_terms", 101))
3204 except (TypeError, ValueError):
3205 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
3206 if n_terms <= 0
or n_terms % 2 == 0:
3207 raise ValueError(f
"{field_name}.params.n_terms must be a positive odd integer.")
3208 return {
"bulk_velocity": bulk_velocity,
"n_terms": n_terms}
3210GENERATED_PROFILE_GENERATORS = {
"square_duct_poiseuille"}
3214 @brief Validate a prescribed_flow field_slice source block.
3215 @param[in] source Source mapping from case.yml.
3216 @param[in] field_name Human-readable YAML path for diagnostics.
3217 @return Normalized source mapping.
3230 unknown = sorted(set(source.keys()) - allowed)
3232 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
3233 field_file = source.get(
"field_file")
3234 grid_file = source.get(
"grid_file")
3235 if not isinstance(field_file, str)
or not field_file.strip():
3236 raise ValueError(f
"{field_name}.field_file must be a non-empty path.")
3237 if not isinstance(grid_file, str)
or not grid_file.strip():
3238 raise ValueError(f
"{field_name}.grid_file must be a non-empty path.")
3239 if source.get(
"source_case")
is None and source.get(
"velocity_scale")
is None:
3240 raise ValueError(f
"{field_name} requires source_case or velocity_scale.")
3243 "type":
"field_slice",
3244 "field_file": field_file.strip(),
3245 "grid_file": grid_file.strip(),
3248 if source.get(
"script")
is not None:
3249 script = source.get(
"script")
3250 if not isinstance(script, str)
or not script.strip():
3251 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
3252 normalized[
"script"] = script.strip()
3253 if source.get(
"source_case")
is not None:
3254 source_case = source.get(
"source_case")
3255 if not isinstance(source_case, str)
or not source_case.strip():
3256 raise ValueError(f
"{field_name}.source_case must be a non-empty path when provided.")
3257 normalized[
"source_case"] = source_case.strip()
3258 if source.get(
"velocity_scale")
is not None:
3259 velocity_scale =
_to_float(source.get(
"velocity_scale"), f
"{field_name}.velocity_scale")
3260 if velocity_scale <= 0.0:
3261 raise ValueError(f
"{field_name}.velocity_scale must be positive.")
3262 normalized[
"velocity_scale"] = velocity_scale
3263 if source.get(
"source_block")
is not None:
3265 source_block = int(source.get(
"source_block"))
3266 except (TypeError, ValueError):
3267 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
3268 if source_block < 0:
3269 raise ValueError(f
"{field_name}.source_block must be a non-negative integer.")
3270 normalized[
"source_block"] = source_block
3271 if source.get(
"output_file")
is not None:
3272 output_file = source.get(
"output_file")
3273 if not isinstance(output_file, str)
or not output_file.strip():
3274 raise ValueError(f
"{field_name}.output_file must be a non-empty path when provided.")
3275 normalized[
"output_file"] = output_file.strip()
3280 @brief Validate the field_slice slice selector.
3281 @param[in] slice_cfg Slice selector mapping.
3282 @param[in] field_name Human-readable YAML path for diagnostics.
3283 @return Normalized selector mapping.
3285 if not isinstance(slice_cfg, dict):
3286 raise ValueError(f
"{field_name} must be a mapping.")
3287 orientation = str(slice_cfg.get(
"orientation",
"opposite")).strip().lower()
3288 if orientation
not in {
"opposite",
"same"}:
3289 raise ValueError(f
"{field_name}.orientation must be 'opposite' or 'same'.")
3290 normal_tolerance =
_to_float(slice_cfg.get(
"normal_tolerance", 0.99), f
"{field_name}.normal_tolerance")
3291 if normal_tolerance <= 0.0
or normal_tolerance > 1.0:
3292 raise ValueError(f
"{field_name}.normal_tolerance must be in the range (0, 1].")
3294 if slice_cfg.get(
"face")
is not None:
3295 unknown = sorted(set(slice_cfg.keys()) - {
"face",
"orientation",
"normal_tolerance"})
3298 f
"Unknown keys in {field_name}: {unknown}. "
3299 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
3301 face = str(slice_cfg.get(
"face",
"")).strip()
3302 if face.lower()
not in BC_FACE_MAP:
3303 raise ValueError(f
"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
3305 "face": BC_FACE_MAP[face.lower()],
3306 "orientation": orientation,
3307 "normal_tolerance": normal_tolerance,
3310 required = {
"axis",
"index",
"normal"}
3311 missing = sorted(key
for key
in required
if slice_cfg.get(key)
is None)
3313 raise ValueError(f
"{field_name} requires either face or axis/index/normal; missing {missing}.")
3314 unknown = sorted(set(slice_cfg.keys()) - {
"axis",
"index",
"normal",
"orientation",
"normal_tolerance"})
3317 f
"Unknown keys in {field_name}: {unknown}. "
3318 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
3320 axis = str(slice_cfg.get(
"axis",
"")).strip()
3321 axis_map = {
"xi":
"Xi",
"eta":
"Eta",
"zeta":
"Zeta"}
3322 if axis.lower()
not in axis_map:
3323 raise ValueError(f
"{field_name}.axis must be one of Xi, Eta, Zeta.")
3324 normal = str(slice_cfg.get(
"normal",
"")).strip()
3325 if normal.lower()
not in BC_FACE_MAP:
3326 raise ValueError(f
"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
3327 normal = BC_FACE_MAP[normal.lower()]
3328 if normal[1:].lower() != axis.lower():
3329 raise ValueError(f
"{field_name}.normal must use the same axis as {field_name}.axis.")
3331 index = int(slice_cfg.get(
"index"))
3332 except (TypeError, ValueError):
3333 raise ValueError(f
"{field_name}.index must be an integer.")
3335 raise ValueError(f
"{field_name}.index must be non-negative.")
3337 "axis": axis_map[axis.lower()],
3340 "orientation": orientation,
3341 "normal_tolerance": normal_tolerance,
3345 target_grid: str =
None, target_block: int = 0,
3346 target_face: str =
None, script: str =
None,
3347 case_path: str =
None) -> dict:
3349 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
3350 @param[in] output_path Path to write.
3351 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
3352 @param[in] params Normalized generator params.
3353 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
3354 @param[in] target_block Target block index when `target_grid` is provided.
3355 @param[in] target_face Target inlet face when `target_grid` is provided.
3356 @param[in] script Optional profile.gen-compatible script override.
3357 @param[in] case_path Current case.yml path used to anchor relative script overrides.
3358 @return Summary dictionary.
3360 n1, n2 = tuple(dims)
3362 if not os.path.isfile(profilegen_script):
3363 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
3367 "square_duct_poiseuille",
3374 str(float(params[
"bulk_velocity"])),
3376 str(int(params[
"n_terms"])),
3383 str(int(target_block)),
3384 f
"--target-face={target_face}",
3386 result = subprocess.run(cmd, text=
True, capture_output=
True)
3387 if result.returncode != 0:
3388 details = (result.stderr
or result.stdout
or "").strip()
3389 raise ValueError(f
"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
3391 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
3392 except (IndexError, json.JSONDecodeError)
as exc:
3393 raise ValueError(f
"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
3394 summary[
"dims"] = tuple(summary[
"dims"])
3398 target_grid: str, target_face: str, target_block: int,
3399 case_path: str) -> dict:
3401 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
3402 @param[in] output_path Path to write.
3403 @param[in] expected_dims Expected PICSLICE dimensions.
3404 @param[in] source Normalized field_slice source mapping.
3405 @param[in] target_grid Target canonical PICGRID path.
3406 @param[in] target_face Target inlet face token.
3407 @param[in] target_block Target block index.
3408 @param[in] case_path Path to current case.yml for relative source resolution.
3409 @return Summary dictionary from profile.gen.
3411 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3416 if not os.path.isfile(profilegen_script):
3417 raise ValueError(f
"profile.gen script not found: {profilegen_script}")
3418 n1, n2 = tuple(expected_dims)
3419 slice_cfg = source[
"slice"]
3433 str(int(source.get(
"source_block", 0))),
3435 str(int(target_block)),
3436 f
"--target-face={target_face}",
3438 slice_cfg[
"orientation"],
3439 "--normal-tolerance",
3440 str(float(slice_cfg[
"normal_tolerance"])),
3442 str(float(velocity_scale)),
3447 if "face" in slice_cfg:
3448 cmd.append(f
"--slice-face={slice_cfg['face']}")
3454 str(int(slice_cfg[
"index"])),
3455 f
"--slice-normal={slice_cfg['normal']}",
3457 result = subprocess.run(cmd, text=
True, capture_output=
True)
3458 if result.returncode != 0:
3459 details = (result.stderr
or result.stdout
or "").strip()
3460 raise ValueError(f
"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
3462 summary = json.loads((result.stdout
or "").strip().splitlines()[-1])
3463 except (IndexError, json.JSONDecodeError)
as exc:
3464 raise ValueError(f
"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}")
from exc
3465 summary[
"dims"] = tuple(summary[
"dims"])
3470 @brief Resolve a path relative to the current case directory.
3471 @param[in] path_value Path from case.yml.
3472 @param[in] case_dir Current case directory.
3473 @return Absolute path.
3475 if not isinstance(path_value, str)
or not path_value.strip():
3476 raise ValueError(
"path value must be a non-empty string.")
3477 if os.path.isabs(path_value):
3478 return os.path.abspath(path_value)
3479 return os.path.abspath(os.path.join(case_dir, path_value))
3483 @brief Resolve field_slice dimensional velocity scale.
3484 @param[in] source Normalized field_slice source mapping.
3485 @param[in] case_dir Current case directory.
3486 @return Positive velocity scale.
3488 if source.get(
"velocity_scale")
is not None:
3489 return float(source[
"velocity_scale"])
3494 source_case_cfg.get(
"properties", {}).get(
"scaling", {}).get(
"velocity_ref"),
3495 "source_case.properties.scaling.velocity_ref",
3497 except AttributeError
as exc:
3498 raise ValueError(
"source_case must contain properties.scaling.velocity_ref.")
from exc
3499 if velocity_scale <= 0.0:
3500 raise ValueError(
"source_case.properties.scaling.velocity_ref must be positive.")
3501 return velocity_scale
3505 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
3506 @param[in] case_cfg Parsed current case config.
3507 @param[in] case_path Current case.yml path.
3508 @param[in] run_dir Current run/precompute directory.
3509 @return Absolute target PICGRID path.
3511 grid_cfg = case_cfg.get(
"grid", {})
or {}
3512 grid_mode = grid_cfg.get(
"mode")
3513 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3514 if grid_mode ==
"file":
3515 source_grid = grid_cfg.get(
"source_file")
3516 if not isinstance(source_grid, str)
or not source_grid.strip():
3517 raise ValueError(
"grid.source_file is required for field_slice target-grid normals.")
3519 if isinstance(grid_cfg.get(
"legacy_conversion"), dict)
and run_dir:
3522 if grid_mode ==
"grid_gen":
3523 generator = grid_cfg.get(
"generator", {})
3524 output_file = generator.get(
"output_file", os.path.join(
"config",
"grid.generated.picgrid"))
3525 candidate = output_file
if os.path.isabs(output_file)
else os.path.abspath(os.path.join(run_dir, output_file))
3526 if os.path.isfile(candidate):
3528 staged = os.path.join(run_dir,
"config",
"grid.run")
3529 if os.path.isfile(staged):
3531 raise ValueError(
"field_slice requires the generated target PICGRID to exist before profile extraction.")
3533 f
"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
3538 @brief Resolve an optional target canonical PICGRID for generated profile sampling.
3539 @param[in] case_cfg Parsed current case config.
3540 @param[in] case_path Current case.yml path.
3541 @param[in] run_dir Current run/precompute directory.
3542 @return Absolute target PICGRID path, or None when no canonical grid is available yet.
3544 grid_mode = (case_cfg.get(
"grid", {})
or {}).get(
"mode")
3545 if grid_mode ==
"programmatic_c":
3551 @brief Write a profile.info summary for generated inlet profiles.
3552 @param[in] config_dir Run/precompute config directory.
3553 @param[in] summaries Generated profile summaries.
3554 @return Path to profile.info.
3556 info_path = os.path.join(config_dir,
"profile.info")
3557 os.makedirs(config_dir, exist_ok=
True)
3558 with open(info_path,
"w")
as fout:
3559 fout.write(
"# PICurv generated profile summary\n")
3560 fout.write(f
"profile_count = {len(summaries)}\n\n")
3561 for idx, summary
in enumerate(summaries):
3562 dims = summary.get(
"dims", (0, 0))
3563 fout.write(f
"[profile_{idx}]\n")
3564 fout.write(f
"generator = {summary.get('generator')}\n")
3565 fout.write(f
"block = {summary.get('block')}\n")
3566 fout.write(f
"face = {summary.get('face')}\n")
3567 fout.write(f
"dimensions = {dims[0]} {dims[1]}\n")
3568 if summary.get(
"bulk_velocity")
is not None:
3569 fout.write(f
"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
3570 if summary.get(
"n_terms")
is not None:
3571 fout.write(f
"n_terms = {summary.get('n_terms')}\n")
3572 fout.write(f
"mean_speed = {summary.get('mean_speed'):.16e}\n")
3573 if "area_mean_speed" in summary:
3574 fout.write(f
"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
3575 if "discrete_mean_speed" in summary:
3576 fout.write(f
"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
3577 fout.write(f
"min_speed = {summary.get('min_speed'):.16e}\n")
3578 fout.write(f
"max_speed = {summary.get('max_speed'):.16e}\n")
3579 if summary.get(
"umax_over_ubulk")
is not None:
3580 fout.write(f
"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
3584 "area_weighted_mean_before_normalization",
3585 "area_weighted_mean_after_normalization",
3602 fout.write(f
"{key} = {summary.get(key)}\n")
3603 fout.write(f
"output_file = {summary.get('path')}\n\n")
3608 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
3609 @param[in] case_path Path to case.yml (used for relative path resolution).
3610 @param[in] run_dir Run directory path.
3611 @param[in] grid_cfg The grid config section from case.yml.
3612 @return Absolute path to generated dimensional PICGRID file.
3613 @throws ValueError on invalid config or generator failure.
3615 generator = grid_cfg.get(
"generator", {})
3616 if not isinstance(generator, dict):
3617 raise ValueError(
"grid.generator must be a mapping when grid.mode is 'grid_gen'.")
3619 case_dir = os.path.dirname(os.path.abspath(case_path))
3620 gridgen_script = generator.get(
"script", os.path.join(GENERATORS_PATH,
"grid.gen"))
3621 if not os.path.isabs(gridgen_script):
3622 gridgen_script = os.path.abspath(os.path.join(case_dir, gridgen_script))
3623 if not os.path.isfile(gridgen_script):
3624 raise ValueError(f
"grid.gen script not found: {gridgen_script}")
3626 config_file = generator.get(
"config_file")
3628 raise ValueError(
"grid.generator.config_file is required when grid.mode is 'grid_gen'.")
3629 if not os.path.isabs(config_file):
3630 config_file = os.path.abspath(os.path.join(case_dir, config_file))
3631 if not os.path.isfile(config_file):
3632 raise ValueError(f
"grid.generator.config_file not found: {config_file}")
3634 output_file = generator.get(
"output_file", os.path.join(
"config",
"grid.generated.picgrid"))
3635 if not os.path.isabs(output_file):
3636 output_file = os.path.abspath(os.path.join(run_dir, output_file))
3637 os.makedirs(os.path.dirname(output_file), exist_ok=
True)
3639 grid_type = generator.get(
"grid_type")
3640 cli_args = generator.get(
"cli_args", [])
3641 if cli_args
is None:
3643 if not isinstance(cli_args, list):
3644 raise ValueError(
"grid.generator.cli_args must be a list of CLI tokens.")
3646 cmd = [sys.executable, gridgen_script,
"-c", config_file]
3648 cmd.append(str(grid_type))
3649 cmd.extend([str(token)
for token
in cli_args])
3650 cmd.extend([
"--output", output_file])
3652 vts_file = generator.get(
"vts_file")
3654 if not os.path.isabs(vts_file):
3655 vts_file = os.path.abspath(os.path.join(run_dir, vts_file))
3656 os.makedirs(os.path.dirname(vts_file), exist_ok=
True)
3657 cmd.extend([
"--vts", vts_file])
3659 stats_file = generator.get(
"stats_file")
3661 if not os.path.isabs(stats_file):
3662 stats_file = os.path.abspath(os.path.join(run_dir, stats_file))
3663 os.makedirs(os.path.dirname(stats_file), exist_ok=
True)
3664 cmd.extend([
"--stats-file", stats_file])
3666 print(f
"[INFO] Grid generator command: {' '.join(cmd)}")
3667 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
3668 if result.returncode != 0:
3669 stderr = (result.stderr
or "").strip()
3670 stdout = (result.stdout
or "").strip()
3671 details = stderr
if stderr
else stdout
3673 f
"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
3676 print(result.stdout.strip())
3678 print(result.stderr.strip())
3680 if not os.path.isfile(output_file):
3681 raise ValueError(f
"grid.gen did not produce expected output file: {output_file}")
3688 @brief Optionally convert a legacy file-grid payload to canonical PICGRID using grid.gen.
3689 @details Activated only when `grid.legacy_conversion.enabled` is true in case.yml.
3690 The converted output remains dimensional; standard nondimensionalization still
3691 occurs via validate_and_nondimensionalize_picgrid().
3692 @param[in] case_path Path to case.yml (for relative path resolution).
3693 @param[in] run_dir Current run directory.
3694 @param[in] grid_cfg Grid section from case.yml.
3695 @param[in] source_grid Absolute or relative path to the original grid file.
3696 @return Grid path that should be fed into validate_and_nondimensionalize_picgrid().
3697 @throws ValueError on invalid converter settings or failed conversion.
3699 legacy_cfg = grid_cfg.get(
"legacy_conversion")
3700 if not isinstance(legacy_cfg, dict):
3703 enabled = legacy_cfg.get(
"enabled",
True)
3704 if enabled
is False:
3706 if not isinstance(enabled, bool):
3707 raise ValueError(
"grid.legacy_conversion.enabled must be a boolean.")
3709 raw_format = str(legacy_cfg.get(
"format",
"legacy1d")).strip().lower()
3711 "legacy1d":
"legacy1d",
3712 "legacy_1d":
"legacy1d",
3713 "les_flat_1d":
"legacy1d",
3714 "les-flat-1d":
"legacy1d",
3716 command = format_aliases.get(raw_format)
3719 "grid.legacy_conversion.format must be one of "
3720 "['legacy1d', 'legacy_1d', 'les_flat_1d', 'les-flat-1d']."
3723 case_dir = os.path.dirname(os.path.abspath(case_path))
3724 gridgen_script = legacy_cfg.get(
"script", os.path.join(GENERATORS_PATH,
"grid.gen"))
3725 if not isinstance(gridgen_script, str)
or not gridgen_script.strip():
3726 raise ValueError(
"grid.legacy_conversion.script must be a non-empty string when provided.")
3727 if not os.path.isabs(gridgen_script):
3728 gridgen_script = os.path.abspath(os.path.join(case_dir, gridgen_script))
3729 if not os.path.isfile(gridgen_script):
3730 raise ValueError(f
"grid.legacy_conversion.script not found: {gridgen_script}")
3732 output_file = legacy_cfg.get(
"output_file", os.path.join(
"config",
"grid.converted.picgrid"))
3733 if not isinstance(output_file, str)
or not output_file.strip():
3734 raise ValueError(
"grid.legacy_conversion.output_file must be a non-empty string when provided.")
3735 if not os.path.isabs(output_file):
3736 output_file = os.path.abspath(os.path.join(run_dir, output_file))
3737 os.makedirs(os.path.dirname(output_file), exist_ok=
True)
3739 axis_columns = legacy_cfg.get(
"axis_columns", [0, 1, 2])
3740 if not isinstance(axis_columns, list)
or len(axis_columns) != 3:
3741 raise ValueError(
"grid.legacy_conversion.axis_columns must be a 3-item list of non-negative integers.")
3743 axis_columns = [int(v)
for v
in axis_columns]
3744 except (TypeError, ValueError)
as exc:
3745 raise ValueError(
"grid.legacy_conversion.axis_columns must contain integers.")
from exc
3746 if any(v < 0
for v
in axis_columns):
3747 raise ValueError(
"grid.legacy_conversion.axis_columns values must be >= 0.")
3749 strict_trailing = legacy_cfg.get(
"strict_trailing",
True)
3750 if not isinstance(strict_trailing, bool):
3751 raise ValueError(
"grid.legacy_conversion.strict_trailing must be a boolean.")
3753 cli_args = legacy_cfg.get(
"cli_args", [])
3754 if cli_args
is None:
3756 if not isinstance(cli_args, list):
3757 raise ValueError(
"grid.legacy_conversion.cli_args must be a list of CLI tokens.")
3768 str(axis_columns[0]),
3769 str(axis_columns[1]),
3770 str(axis_columns[2]),
3774 cmd.append(
"--strict-trailing")
3776 cmd.append(
"--allow-trailing")
3777 cmd.extend(str(token)
for token
in cli_args)
3779 print(f
"[INFO] Legacy grid conversion command: {' '.join(cmd)}")
3780 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
3781 if result.returncode != 0:
3782 stderr = (result.stderr
or "").strip()
3783 stdout = (result.stdout
or "").strip()
3784 details = stderr
if stderr
else stdout
3786 f
"legacy grid conversion failed with exit code {result.returncode}. Details:\n{details}"
3789 print(result.stdout.strip())
3791 print(result.stderr.strip())
3792 if not os.path.isfile(output_file):
3793 raise ValueError(f
"legacy grid conversion did not produce expected output file: {output_file}")
3809 "symmetry":
"SYMMETRY",
3812 "periodic":
"PERIODIC",
3819 "required_params": set(),
3820 "optional_params": set(),
3822 "constant_velocity": {
3824 "required_params": {
"vx",
"vy",
"vz"},
3825 "optional_params": set(),
3828 "types": {
"OUTLET"},
3829 "required_params": set(),
3830 "optional_params": set(),
3834 "required_params": {
"v_max"},
3835 "optional_params": set(),
3837 "prescribed_flow": {
3839 "required_params": {
"source"},
3840 "optional_params": set(),
3843 "types": {
"PERIODIC"},
3844 "required_params": set(),
3845 "optional_params": set(),
3848 "types": {
"PERIODIC"},
3849 "required_params": {
"target_flux"},
3850 "optional_params": {
"apply_trim"},
3854_NUMERIC_BC_PARAMS = {
"vx",
"vy",
"vz",
"v_max",
"target_flux"}
3855_BOOL_BC_PARAMS = {
"apply_trim"}
3859 @brief Validate the structured source block for prescribed_flow BCs.
3860 @param[in] source Source mapping from case.yml.
3861 @param[in] field_name Human-readable YAML path for diagnostics.
3862 @return Normalized source mapping.
3863 @throws ValueError on invalid source contract.
3865 if not isinstance(source, dict):
3866 raise ValueError(f
"{field_name} must be a mapping with type: file, generated, or field_slice.")
3867 source_type = str(source.get(
"type",
"")).strip().lower()
3868 if source_type ==
"file":
3869 path = source.get(
"path")
3870 if not isinstance(path, str)
or not path.strip():
3871 raise ValueError(f
"{field_name}.path must be a non-empty file path.")
3872 unknown = sorted(set(source.keys()) - {
"type",
"path"})
3874 raise ValueError(f
"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
3875 return {
"type":
"file",
"path": path.strip()}
3877 if source_type ==
"generated":
3878 generator = str(source.get(
"generator",
"")).strip().lower()
3879 if generator
not in GENERATED_PROFILE_GENERATORS:
3881 f
"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
3882 f
"(got '{source.get('generator')}')."
3884 unknown = sorted(set(source.keys()) - {
"type",
"generator",
"script",
"output_file",
"params"})
3887 f
"Unknown keys in {field_name}: {unknown}. "
3888 "Allowed: ['generator', 'output_file', 'params', 'script', 'type']."
3891 "type":
"generated",
3892 "generator": generator,
3895 output_file = source.get(
"output_file")
3896 if output_file
is not None:
3897 if not isinstance(output_file, str)
or not output_file.strip():
3898 raise ValueError(f
"{field_name}.output_file must be a non-empty path when provided.")
3899 normalized[
"output_file"] = output_file.strip()
3900 script = source.get(
"script")
3901 if script
is not None:
3902 if not isinstance(script, str)
or not script.strip():
3903 raise ValueError(f
"{field_name}.script must be a non-empty path when provided.")
3904 normalized[
"script"] = script.strip()
3907 if source_type ==
"field_slice":
3910 raise ValueError(f
"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
3914 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
3915 @param[in] face Canonical BC face token.
3916 @param[in] block_dims (IM, JM, KM) node counts.
3917 @return (n1, n2) dimensions in profile storage order.
3919 im, jm, km = block_dims
3920 if min(im, jm, km) < 2:
3922 f
"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
3924 if face
in {
"-Xi",
"+Xi"}:
3925 return (km - 1, jm - 1)
3926 if face
in {
"-Eta",
"+Eta"}:
3927 return (km - 1, im - 1)
3928 if face
in {
"-Zeta",
"+Zeta"}:
3929 return (jm - 1, im - 1)
3930 raise ValueError(f
"Unsupported face '{face}' for prescribed_flow profile dimensions.")
3934 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
3935 @param[in] case_cfg Parsed case.yml configuration.
3936 @param[in] case_path Path to case.yml for relative path resolution.
3937 @param[in] run_dir Current run directory, used for optional generated grid outputs.
3938 @return List of (IM, JM, KM) node-count tuples.
3939 @throws ValueError when dimensions cannot be resolved.
3941 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
3942 grid_cfg = case_cfg.get(
"grid", {})
3943 grid_mode = grid_cfg.get(
"mode")
3944 case_dir = os.path.dirname(os.path.abspath(case_path))
if case_path
else os.getcwd()
3946 if grid_mode ==
"programmatic_c":
3947 settings = grid_cfg.get(
"programmatic_settings", {})
3949 for key
in (
"im",
"jm",
"km"):
3950 raw = settings.get(key)
3952 raise ValueError(f
"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
3953 if isinstance(raw, list):
3954 if len(raw) != num_blocks:
3956 f
"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
3960 values = [raw] * num_blocks
3962 values = [int(v) + 1
for v
in values]
3963 except (TypeError, ValueError):
3964 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
3965 if any(v <= 1
for v
in values):
3966 raise ValueError(f
"grid.programmatic_settings.{key} values must be positive integer cell counts.")
3967 dims_by_axis.append(values)
3968 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
3970 if grid_mode ==
"file":
3971 source_grid = grid_cfg.get(
"source_file")
3972 if not isinstance(source_grid, str)
or not source_grid.strip():
3973 raise ValueError(
"grid.source_file is required for file-grid prescribed_flow profile validation.")
3974 if not os.path.isabs(source_grid):
3975 source_grid = os.path.abspath(os.path.join(case_dir, source_grid))
3976 if isinstance(grid_cfg.get(
"legacy_conversion"), dict)
and run_dir:
3980 if grid_mode ==
"grid_gen":
3981 generator = grid_cfg.get(
"generator", {})
3982 output_file = generator.get(
"output_file", os.path.join(
"config",
"grid.generated.picgrid"))
3985 candidates.append(output_file
if os.path.isabs(output_file)
else os.path.abspath(os.path.join(run_dir, output_file)))
3986 candidates.append(os.path.join(run_dir,
"config",
"grid.run"))
3987 for candidate
in candidates:
3988 if os.path.isfile(candidate):
3991 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
3992 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
3995 raise ValueError(f
"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
3998 profile_grid_dims: list =
None) -> list:
4000 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
4001 @param[in] run_dir Run/precompute directory root.
4002 @param[in] case_cfg Parsed case.yml.
4003 @param[in] case_path Path to case.yml for relative grid/source resolution.
4004 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
4005 @return List of generated profile summaries.
4009 bc.get(
"handler") ==
"prescribed_flow"
4010 and ((bc.get(
"params")
or {}).get(
"source")
or {}).get(
"type")
in {
"generated",
"field_slice"}
4011 for block
in prepared_blocks
for bc
in block
4014 if profile_grid_dims
is None:
4017 config_dir = os.path.join(run_dir,
"config")
4019 generated_target_grid =
None
4021 for block_idx, block
in enumerate(prepared_blocks):
4023 if bc.get(
"handler") !=
"prescribed_flow":
4025 source = (bc.get(
"params")
or {}).get(
"source", {})
4026 if source.get(
"type")
not in {
"generated",
"field_slice"}:
4031 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
4032 default_output = os.path.join(
4033 "config", f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.picslice"
4037 source.get(
"output_file"),
4039 default_to_config_dir=
True,
4041 if source.get(
"type") ==
"generated" and source[
"generator"] ==
"square_duct_poiseuille":
4042 if generated_target_grid
is None:
4048 target_grid=generated_target_grid,
4049 target_block=block_idx,
4051 script=source.get(
"script"),
4052 case_path=case_path,
4054 elif source.get(
"type") ==
"generated":
4055 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
4057 if target_grid
is None:
4068 summary.update({
"block": block_idx,
"face": face})
4069 summaries.append(summary)
4071 f
"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
4072 f
"{os.path.relpath(output_path)} dims={summary['dims']}"
4077 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
4082 @brief Convert a YAML scalar to float with a clear error message.
4083 @param[in] value Argument passed to `_to_float()`.
4084 @param[in] field_name Argument passed to `_to_float()`.
4085 @return Value returned by `_to_float()`.
4089 except (TypeError, ValueError):
4090 raise ValueError(f
"'{field_name}' must be numeric (got {value!r}).")
4094 @brief Convert a YAML scalar/string to bool with a clear error message.
4095 @param[in] value Argument passed to `_to_bool()`.
4096 @param[in] field_name Argument passed to `_to_bool()`.
4097 @return Value returned by `_to_bool()`.
4099 if isinstance(value, bool):
4101 if isinstance(value, str):
4102 raw = value.strip().lower()
4103 if raw
in {
"true",
"1",
"yes"}:
4105 if raw
in {
"false",
"0",
"no"}:
4107 raise ValueError(f
"'{field_name}' must be boolean (got {value!r}).")
4111 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
4112 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
4113 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
4114 @return Value returned by `normalize_boundary_conditions_layout()`.
4116 if not all_blocks_bcs:
4117 raise ValueError(
"The 'boundary_conditions' section in case.yml is empty.")
4119 is_simple_list = isinstance(all_blocks_bcs[0], dict)
4120 if num_blocks == 1
and is_simple_list:
4121 all_blocks_bcs = [all_blocks_bcs]
4122 elif is_simple_list
and num_blocks > 1:
4124 f
"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
4125 "Use a list-of-lists, one inner list per block."
4128 if len(all_blocks_bcs) != num_blocks:
4130 f
"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
4132 return all_blocks_bcs
4136 @brief Validate BC entries against currently supported C-side handlers/types and
4137 @details return normalized entries ready for bcs.run generation.
4138 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
4139 @return Value returned by `validate_and_prepare_boundary_conditions()`.
4141 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
4142 scales = case_cfg.get(
'properties', {}).get(
'scaling', {})
4143 L_ref =
_to_float(scales.get(
'length_ref'),
"properties.scaling.length_ref")
4144 U_ref =
_to_float(scales.get(
'velocity_ref'),
"properties.scaling.velocity_ref")
4146 raise ValueError(
"properties.scaling.velocity_ref must be non-zero for non-dimensionalization.")
4148 raise ValueError(
"properties.scaling.length_ref must be non-zero for non-dimensionalization.")
4151 prepared_blocks = []
4153 expected_faces = {
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta"}
4154 axis_pairs = [(
"-Xi",
"+Xi"), (
"-Eta",
"+Eta"), (
"-Zeta",
"+Zeta")]
4156 for bi, block_bcs
in enumerate(all_blocks_bcs):
4157 if not isinstance(block_bcs, list):
4158 raise ValueError(f
"boundary_conditions[{bi}] must be a list of face configs.")
4163 for idx, bc
in enumerate(block_bcs):
4164 if not isinstance(bc, dict):
4165 raise ValueError(f
"boundary_conditions[{bi}][{idx}] must be a mapping.")
4167 for req
in (
"face",
"type",
"handler"):
4169 raise ValueError(f
"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
4171 face_raw = str(bc[
"face"]).strip()
4172 face_key = face_raw.lower()
4173 face = BC_FACE_MAP.get(face_key)
4176 f
"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
4177 f
"Supported: {sorted(expected_faces)}."
4179 if face
in seen_faces:
4180 raise ValueError(f
"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
4181 seen_faces[face] = idx
4183 bc_type_raw = str(bc[
"type"]).strip()
4184 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
4187 f
"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
4188 f
"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
4191 handler = str(bc[
"handler"]).strip().lower()
4192 handler_spec = BC_HANDLER_SPECS.get(handler)
4193 if handler_spec
is None:
4195 f
"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
4196 f
"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
4198 if bc_type
not in handler_spec[
"types"]:
4200 f
"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
4203 params = bc.get(
"params", {})
4206 if not isinstance(params, dict):
4207 raise ValueError(f
"'params' for block {bi}, face {face} must be a mapping.")
4210 if "vector" in params
or "velocity" in params:
4212 f
"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
4213 "Use scalar keys 'vx', 'vy', 'vz'."
4216 required = handler_spec[
"required_params"]
4217 optional = handler_spec[
"optional_params"]
4218 allowed = required | optional
4220 missing = sorted(required - set(params.keys()))
4223 f
"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
4225 unknown = sorted(set(params.keys()) - allowed)
4228 f
"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
4229 f
"Allowed: {sorted(allowed)}."
4232 converted_params = {}
4233 for key, value
in params.items():
4234 if key
in _NUMERIC_BC_PARAMS:
4235 numeric =
_to_float(value, f
"boundary_conditions[{bi}][{idx}].params.{key}")
4236 if key
in {
"vx",
"vy",
"vz",
"v_max"}:
4237 converted_params[key] = numeric / U_ref
4238 elif key ==
"target_flux":
4239 converted_params[key] = numeric / (U_ref * (L_ref ** 2))
4240 elif key
in _BOOL_BC_PARAMS:
4241 converted_params[key] =
_to_bool(value, f
"boundary_conditions[{bi}][{idx}].params.{key}")
4242 elif handler ==
"prescribed_flow" and key ==
"source":
4244 value, f
"boundary_conditions[{bi}][{idx}].params.source"
4248 converted_params[key] = value
4250 prepared_block.append({
4254 "params": converted_params,
4257 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
4260 f
"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
4261 "Provide all six faces explicitly."
4265 face_map = {entry[
"face"]: entry
for entry
in prepared_block}
4266 for neg_face, pos_face
in axis_pairs:
4267 neg = face_map[neg_face]
4268 pos = face_map[pos_face]
4269 neg_periodic = (neg[
"type"] ==
"PERIODIC")
4270 pos_periodic = (pos[
"type"] ==
"PERIODIC")
4271 if neg_periodic != pos_periodic:
4273 f
"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
4276 driven_handlers = {
"constant_flux"}
4277 if (neg[
"handler"]
in driven_handlers)
or (pos[
"handler"]
in driven_handlers):
4278 if neg[
"handler"] != pos[
"handler"]:
4280 f
"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
4282 if not (neg_periodic
and pos_periodic):
4284 f
"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
4287 prepared_blocks.append(prepared_block)
4289 return prepared_blocks
4294 @brief Render an internal schema path tuple as a user-facing YAML path.
4295 @param[in] path Internal path tuple.
4296 @return Dotted YAML path.
4298 return ".".join(part
for part
in path
if part !=
"[]")
or "<root>"
4303 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
4304 @param[in] schema Role schema mapping.
4305 @param[in] path Internal path tuple.
4306 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
4310 for idx, part
in enumerate(path):
4313 candidate = path[:idx] + (
"*",) + path[idx + 1:]
4314 if candidate
in schema:
4315 return schema[candidate]
4321 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
4322 @param[in] schema Role schema mapping.
4323 @param[in] path Current internal YAML path tuple.
4324 @param[in] key Unsupported YAML key.
4325 @param[in] allowed Allowed keys at the current path.
4326 @return Optional hint string.
4329 allowed_strings = sorted(str(item)
for item
in allowed)
4330 lower_matches = [item
for item
in allowed_strings
if item.lower() == key.lower()]
4331 close_matches = lower_matches
or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
4333 hints.append(f
"Did you mean '{close_matches[0]}'?")
4336 for schema_path, schema_allowed
in schema.items():
4337 if schema_path == path
or not schema_allowed:
4339 if key
in schema_allowed:
4342 hints.append(f
"This key is valid at: {', '.join(sorted(valid_paths))}.")
4344 return " ".join(hints)
4349 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
4350 @param[in] cfg Parsed YAML node.
4351 @param[in] schema Role schema mapping.
4352 @param[in] file_path Source file path for diagnostics.
4353 @param[in,out] errors Validation error accumulator.
4354 @param[in] path Current internal YAML path tuple.
4356 if isinstance(cfg, dict):
4358 if allowed
is not False and allowed
is not None:
4359 unknown = sorted(str(key)
for key
in cfg.keys()
if key
not in allowed)
4362 hint_text = f
" {hint}" if hint
else ""
4364 f
" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
4365 f
"Allowed keys: {sorted(allowed)}.{hint_text}"
4369 for key, value
in cfg.items():
4371 elif isinstance(cfg, list):
4378 "properties",
"run_control",
"grid",
"models",
"boundary_conditions",
"solver_parameters",
4380 (
"run_control",): {
"start_step",
"total_steps",
"dt_physical"},
4381 (
"properties",): {
"scaling",
"fluid",
"initial_conditions"},
4382 (
"properties",
"scaling"): {
"length_ref",
"velocity_ref"},
4383 (
"properties",
"fluid"): {
"density",
"viscosity"},
4384 (
"properties",
"initial_conditions"): {
4385 "mode",
"generator",
"params",
"field",
"source_file",
4386 "u_physical",
"v_physical",
"w_physical",
"peak_velocity_physical",
4387 "velocity_physical",
"flow_direction",
4389 (
"properties",
"initial_conditions",
"params"):
None,
4391 "mode",
"source_file",
"programmatic_settings",
"generator",
"legacy_conversion",
4392 "da_processors_x",
"da_processors_y",
"da_processors_z",
4394 (
"grid",
"programmatic_settings"): {
4395 "im",
"jm",
"km",
"xMins",
"xMaxs",
"yMins",
"yMaxs",
"zMins",
"zMaxs",
4396 "rxs",
"rys",
"rzs",
"cgrids",
4397 "da_processors_x",
"da_processors_y",
"da_processors_z",
4399 (
"grid",
"generator"): {
4400 "script",
"config_file",
"grid_type",
"cli_args",
"output_file",
"stats_file",
"vts_file",
4402 "config-file",
"grid-type",
"output-file",
"stats-file",
"vts-file",
4404 (
"grid",
"legacy_conversion"): {
4405 "enabled",
"format",
"script",
"output_file",
"axis_columns",
"strict_trailing",
"cli_args",
4407 (
"models",): {
"domain",
"physics",
"statistics"},
4408 (
"models",
"domain"): {
"blocks"},
4409 (
"models",
"physics"): {
"dimensionality",
"fsi",
"particles",
"turbulence"},
4410 (
"models",
"physics",
"fsi"): {
"immersed",
"moving_fsi"},
4411 (
"models",
"physics",
"particles"): {
"count",
"init_mode",
"restart_mode",
"point_source"},
4412 (
"models",
"physics",
"particles",
"point_source"): {
"x",
"y",
"z"},
4413 (
"models",
"physics",
"turbulence"): {
"les",
"rans",
"wall_function"},
4414 (
"models",
"physics",
"turbulence",
"les"): {
4415 "enabled",
"model",
"constant_cs",
"max_cs",
"dynamic_frequency",
"test_filter",
4417 (
"models",
"physics",
"turbulence",
"rans"): {
"enabled",
"model"},
4418 (
"models",
"physics",
"turbulence",
"wall_function"): {
"enabled",
"model",
"roughness_height"},
4419 (
"models",
"statistics"): {
"time_averaging"},
4420 (
"boundary_conditions",
"[]"): {
"face",
"type",
"handler",
"params"},
4421 (
"boundary_conditions",
"[]",
"[]"): {
"face",
"type",
"handler",
"params"},
4422 (
"boundary_conditions",
"[]",
"params"):
None,
4423 (
"boundary_conditions",
"[]",
"[]",
"params"):
None,
4424 (
"solver_parameters",):
None,
4430 "operation_mode",
"strategy",
"tolerances",
"momentum_solver",
"poisson_solver",
4431 "pressure_solver",
"interpolation",
"petsc_passthrough_options",
"verification",
4432 "scalar_transport",
"solution_convergence",
4434 (
"operation_mode",): {
"eulerian_field_source",
"analytical_type",
"uniform_flow"},
4435 (
"operation_mode",
"uniform_flow"): {
"u",
"v",
"w"},
4436 (
"strategy",): {
"momentum_solver",
"central_diff"},
4438 "max_iterations",
"absolute_tol",
"relative_tol",
"step_tol",
4439 "residual_absolute_tol",
"residual_relative_tol",
4441 (
"momentum_solver",): {
"type",
"dual_time_picard_jameson_rk",
"dual_time_picard_rk4"},
4442 (
"momentum_solver",
"dual_time_picard_jameson_rk"): {
4443 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
4444 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
4446 (
"momentum_solver",
"dual_time_picard_jameson_rk",
"pseudo_cfl"): {
4447 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
4449 (
"momentum_solver",
"dual_time_picard_rk4"): {
4450 "max_pseudo_steps",
"absolute_tol",
"relative_tol",
"step_tol",
"pseudo_cfl",
4451 "jameson_residual_noise_allowance_factor",
"rk4_residual_noise_allowance_factor",
4453 (
"momentum_solver",
"dual_time_picard_rk4",
"pseudo_cfl"): {
4454 "initial",
"minimum",
"maximum",
"growth_factor",
"reduction_factor",
4456 (
"poisson_solver",): {
4457 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
4458 "gmres",
"preconditioner",
"multigrid",
4460 (
"pressure_solver",): {
4461 "method",
"absolute_tolerance",
"relative_tolerance",
"max_iterations",
"tolerance",
4462 "gmres",
"preconditioner",
"multigrid",
4464 (
"poisson_solver",
"gmres"): {
"restart"},
4465 (
"pressure_solver",
"gmres"): {
"restart"},
4466 (
"poisson_solver",
"preconditioner"): {
"type"},
4467 (
"pressure_solver",
"preconditioner"): {
"type"},
4468 (
"poisson_solver",
"multigrid"): {
4469 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
4471 (
"pressure_solver",
"multigrid"): {
4472 "levels",
"pre_sweeps",
"post_sweeps",
"cycle",
"mode",
"semi_coarsening",
"level_solvers",
4474 (
"poisson_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
4475 (
"pressure_solver",
"multigrid",
"semi_coarsening"): {
"i",
"j",
"k"},
4476 (
"poisson_solver",
"multigrid",
"level_solvers",
"*"): {
4477 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
4479 (
"pressure_solver",
"multigrid",
"level_solvers",
"*"): {
4480 "method",
"preconditioner",
"ksp_type",
"pc_type",
"max_it",
"rtol",
"atol",
4482 (
"interpolation",): {
"method"},
4483 (
"petsc_passthrough_options",):
None,
4484 (
"verification",): {
"sources"},
4485 (
"verification",
"sources"): {
"diffusivity",
"scalar"},
4486 (
"verification",
"sources",
"diffusivity"): {
"mode",
"profile",
"gamma0",
"slope_x"},
4487 (
"verification",
"sources",
"scalar"): {
4488 "mode",
"profile",
"value",
"phi0",
"slope_x",
"amplitude",
"kx",
"ky",
"kz",
4490 (
"scalar_transport",): {
"schmidt_number",
"turbulent_schmidt_number"},
4491 (
"solution_convergence",): {
"enabled",
"mode",
"periodic_deterministic",
"statistical_steady"},
4492 (
"solution_convergence",
"periodic_deterministic"): {
"period_steps"},
4493 (
"solution_convergence",
"statistical_steady"): {
"window_steps"},
4498 (): {
"logging",
"profiling",
"diagnostics",
"io",
"solver_monitoring"},
4499 (
"logging",): {
"verbosity",
"enabled_functions"},
4500 (
"profiling",): {
"timestep_output",
"final_summary"},
4501 (
"profiling",
"timestep_output"): {
"mode",
"functions",
"file"},
4502 (
"profiling",
"final_summary"): {
"enabled"},
4503 (
"diagnostics",): {
"petsc",
"runtime_memory_log"},
4504 (
"diagnostics",
"petsc"): {
4505 "malloc_debug",
"malloc_test",
"malloc_dump",
"malloc_view",
"malloc_view_threshold",
4506 "memory_view",
"log_view",
"log_view_memory",
"log_all",
"log_trace",
4507 "objects_dump",
"options_left",
4509 (
"diagnostics",
"runtime_memory_log"): {
"enabled",
"file"},
4511 "data_output_frequency",
"particle_console_output_frequency",
"particle_log_interval",
4514 (
"io",
"directories"): {
"output",
"restart",
"log",
"eulerian_subdir",
"particle_subdir"},
4515 (
"solver_monitoring",): {
"poisson",
"petsc_passthrough_options"},
4516 (
"solver_monitoring",
"poisson"): {
"pic_true_residual",
"true_residual",
"converged_reason",
"view"},
4517 (
"solver_monitoring",
"petsc_passthrough_options"):
None,
4523 "run_control",
"source_data",
"global_operations",
"eulerian_pipeline",
4524 "lagrangian_pipeline",
"statistics_pipeline",
"statistics_output_prefix",
"io",
4527 "start_step",
"end_step",
"step_interval",
"startTime",
"endTime",
"timeStep",
4529 (
"source_data",): {
"directory",
"input_extensions"},
4530 (
"source_data",
"input_extensions"): {
"eulerian",
"particle"},
4531 (
"global_operations",): {
"dimensionalize"},
4532 (
"eulerian_pipeline",
"[]"): {
"task",
"input_field",
"output_field",
"field",
"reference_point"},
4533 (
"lagrangian_pipeline",
"[]"): {
"task",
"input_field",
"output_field"},
4534 (
"statistics_pipeline",): {
"output_prefix",
"tasks"},
4535 (
"statistics_pipeline",
"tasks",
"[]"): {
"task"},
4537 "output_directory",
"output_filename_prefix",
"particle_filename_prefix",
"output_particles",
4538 "particle_subsampling_frequency",
"input_extensions",
"eulerian_fields_averaged",
4539 "eulerian_fields",
"particle_fields",
4541 (
"io",
"input_extensions"): {
"eulerian",
"particle"},
4546 (): {
"scheduler",
"resources",
"notifications",
"execution"},
4547 (
"scheduler",): {
"type"},
4548 (
"resources",): {
"account",
"partition",
"nodes",
"ntasks_per_node",
"mem",
"time"},
4549 (
"notifications",): {
"mail_user",
"mail_type"},
4551 "module_setup",
"launcher",
"launcher_args",
"extra_sbatch",
"walltime_guard",
4553 (
"execution",
"extra_sbatch"):
None,
4554 (
"execution",
"walltime_guard"): {
4555 "enabled",
"warmup_steps",
"multiplier",
"min_seconds",
"estimator_alpha",
4562 "base_configs",
"study_type",
"parameters",
"parameter_sets",
"metrics",
"plotting",
"execution",
4564 (
"base_configs",): {
"case",
"solver",
"monitor",
"post"},
4565 (
"parameters",):
None,
4566 (
"parameter_sets",
"[]"):
None,
4567 (
"metrics",
"[]"): {
4568 "name",
"source",
"file_glob",
"column",
"reduction",
"normalize_by_parameter",
4569 "numerator_column",
"denominator_column",
"denominator_floor",
4571 (
"plotting",): {
"enabled",
"output_format"},
4572 (
"execution",): {
"max_concurrent_array_tasks"},
4577 case_path: str, solver_path: str, monitor_path: str):
4579 @brief Validates all solver input configs before any work is done.
4580 @details Checks for required sections, required keys, and physical sanity.
4581 Exits with a clear error message on the first problem found.
4582 @param[in] case_cfg Parsed case YAML dictionary.
4583 @param[in] solver_cfg Parsed solver YAML dictionary.
4584 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4585 @param[in] case_path Path to case file (for error messages).
4586 @param[in] solver_path Path to solver file (for error messages).
4587 @param[in] monitor_path Path to monitor file (for error messages).
4588 @throws SystemExit on validation failure.
4592 eulerian_source_mode =
"solve"
4599 required_case_sections = [
'properties',
'run_control',
'grid',
'models',
'boundary_conditions']
4600 for section
in required_case_sections:
4601 if section
not in case_cfg:
4602 errors.append(f
" {case_path}: missing required section '{section}'.")
4608 props = case_cfg.get(
'properties', {})
4609 for group, keys
in [(
'scaling', [
'length_ref',
'velocity_ref']),
4610 (
'fluid', [
'density',
'viscosity'])]:
4611 sub = props.get(group, {})
4613 errors.append(f
" {case_path}: missing 'properties.{group}' section.")
4617 errors.append(f
" {case_path}: missing key 'properties.{group}.{k}'.")
4620 rc = case_cfg.get(
'run_control', {})
4621 for k
in [
'start_step',
'total_steps',
'dt_physical']:
4623 errors.append(f
" {case_path}: missing key 'run_control.{k}'.")
4627 density = float(props.get(
'fluid', {}).get(
'density', 0))
4628 viscosity = float(props.get(
'fluid', {}).get(
'viscosity', 0))
4629 dt = float(rc.get(
'dt_physical', 0))
4631 errors.append(f
" {case_path}: 'properties.fluid.density' must be positive (got {density}).")
4633 errors.append(f
" {case_path}: 'properties.fluid.viscosity' must be non-negative (got {viscosity}).")
4635 errors.append(f
" {case_path}: 'run_control.dt_physical' must be positive (got {dt}).")
4636 except (TypeError, ValueError):
4640 grid_cfg = case_cfg.get(
'grid', {})
4641 grid_mode = grid_cfg.get(
'mode')
4642 valid_grid_modes = [
'file',
'programmatic_c',
'grid_gen']
4643 if grid_mode
not in valid_grid_modes:
4644 errors.append(f
" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
4645 elif grid_mode ==
'file':
4646 source_file = grid_cfg.get(
'source_file')
4648 errors.append(f
" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
4650 source_abs = source_file
if os.path.isabs(source_file)
else os.path.abspath(os.path.join(os.path.dirname(case_path), source_file))
4651 if not os.path.isfile(source_abs):
4652 errors.append(f
" {case_path}: grid.source_file does not exist: {source_abs}")
4654 legacy_cfg = grid_cfg.get(
"legacy_conversion")
4655 if legacy_cfg
is not None:
4656 if not isinstance(legacy_cfg, dict):
4657 errors.append(f
" {case_path}: grid.legacy_conversion must be a mapping when provided.")
4659 enabled = legacy_cfg.get(
"enabled",
True)
4660 if not isinstance(enabled, bool):
4661 errors.append(f
" {case_path}: grid.legacy_conversion.enabled must be a boolean.")
4663 fmt = legacy_cfg.get(
"format")
4665 normalized_fmt = str(fmt).strip().lower()
4666 allowed_formats = {
"legacy1d",
"legacy_1d",
"les_flat_1d",
"les-flat-1d"}
4667 if normalized_fmt
not in allowed_formats:
4669 f
" {case_path}: grid.legacy_conversion.format must be one of "
4670 f
"{sorted(allowed_formats)} (got '{fmt}')."
4673 script_path = legacy_cfg.get(
"script")
4674 if script_path
is not None:
4675 if not isinstance(script_path, str)
or not script_path.strip():
4676 errors.append(f
" {case_path}: grid.legacy_conversion.script must be a non-empty string.")
4678 script_abs = script_path
if os.path.isabs(script_path)
else os.path.abspath(os.path.join(os.path.dirname(case_path), script_path))
4679 if not os.path.isfile(script_abs):
4680 errors.append(f
" {case_path}: grid.legacy_conversion.script does not exist: {script_abs}")
4682 output_file = legacy_cfg.get(
"output_file")
4683 if output_file
is not None and (
not isinstance(output_file, str)
or not output_file.strip()):
4684 errors.append(f
" {case_path}: grid.legacy_conversion.output_file must be a non-empty string when provided.")
4686 axis_columns = legacy_cfg.get(
"axis_columns")
4687 if axis_columns
is not None:
4688 if not isinstance(axis_columns, list)
or len(axis_columns) != 3:
4689 errors.append(f
" {case_path}: grid.legacy_conversion.axis_columns must be a 3-item integer list.")
4691 for idx, value
in enumerate(axis_columns):
4692 if not isinstance(value, int)
or value < 0:
4694 f
" {case_path}: grid.legacy_conversion.axis_columns[{idx}] must be a non-negative integer (got {value})."
4697 strict_trailing = legacy_cfg.get(
"strict_trailing")
4698 if strict_trailing
is not None and not isinstance(strict_trailing, bool):
4699 errors.append(f
" {case_path}: grid.legacy_conversion.strict_trailing must be a boolean when provided.")
4701 cli_args = legacy_cfg.get(
"cli_args")
4702 if cli_args
is not None and not isinstance(cli_args, list):
4703 errors.append(f
" {case_path}: grid.legacy_conversion.cli_args must be a list of CLI tokens.")
4704 elif grid_mode ==
'programmatic_c':
4705 grid_settings = grid_cfg.get(
'programmatic_settings')
4706 if not grid_settings:
4707 errors.append(f
" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
4708 elif not isinstance(grid_settings, dict):
4709 errors.append(f
" {case_path}: 'grid.programmatic_settings' must be a mapping.")
4710 elif grid_mode ==
'grid_gen':
4711 gen_cfg = grid_cfg.get(
'generator')
4712 if not isinstance(gen_cfg, dict):
4713 errors.append(f
" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
4717 config_file = gen_cfg.get(
'config_file')
4719 errors.append(f
" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
4721 config_abs = config_file
if os.path.isabs(config_file)
else os.path.abspath(os.path.join(os.path.dirname(case_path), config_file))
4722 if not os.path.isfile(config_abs):
4723 errors.append(f
" {case_path}: grid.generator.config_file does not exist: {config_abs}")
4725 grid_type = gen_cfg.get(
'grid_type')
4726 if grid_type
is not None and str(grid_type)
not in {
'cpipe',
'pipe',
'warp'}:
4727 errors.append(f
" {case_path}: grid.generator.grid_type must be one of ['cpipe','pipe','warp'] (got '{grid_type}').")
4729 cli_args = gen_cfg.get(
'cli_args', [])
4730 if cli_args
is not None and not isinstance(cli_args, list):
4731 errors.append(f
" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
4734 except ValueError
as e:
4735 errors.append(f
" {case_path}: {e}")
4738 prepared_blocks =
None
4741 except ValueError
as e:
4742 errors.append(f
" {case_path}: {e}")
4745 ic = props.get(
'initial_conditions', {})
4747 errors.append(f
" {case_path}: missing 'properties.initial_conditions' section.")
4748 elif not isinstance(ic, dict):
4749 errors.append(f
" {case_path}: 'properties.initial_conditions' must be a mapping.")
4750 elif 'mode' not in ic:
4752 f
" {case_path}: missing key 'properties.initial_conditions.mode'. "
4753 "Specify 'generated' or 'file' explicitly."
4758 except KeyError
as e:
4759 errors.append(f
" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
4760 except ValueError
as e:
4761 errors.append(f
" {case_path}: {e}")
4764 particles_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'particles', {})
4765 if particles_cfg
and not isinstance(particles_cfg, dict):
4766 errors.append(f
" {case_path}: 'models.physics.particles' must be a mapping.")
4767 elif isinstance(particles_cfg, dict):
4768 init_mode_raw = particles_cfg.get(
'init_mode',
'Surface')
4771 except ValueError
as e:
4772 errors.append(f
" {case_path}: {e}")
4775 restart_mode = particles_cfg.get(
'restart_mode')
4776 if restart_mode
is not None and str(restart_mode).lower()
not in {
"init",
"load"}:
4778 f
" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
4780 elif 'restart_mode' not in particles_cfg:
4782 start_step = int(rc.get(
'start_step', 0))
4783 particle_count = int(particles_cfg.get(
'count', 0)
or 0)
4784 except (TypeError, ValueError):
4787 if start_step > 0
and particle_count > 0:
4789 f
"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
4790 "(run_control.start_step > 0, count > 0). C will default to 'load'."
4794 point_cfg = particles_cfg.get(
'point_source', {})
4795 if not isinstance(point_cfg, dict):
4796 errors.append(f
" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
4798 for coord
in (
'x',
'y',
'z'):
4799 if coord
not in point_cfg:
4801 f
" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
4805 turbulence_cfg = case_cfg.get(
'models', {}).get(
'physics', {}).get(
'turbulence', {})
4806 if turbulence_cfg
is not None and not isinstance(turbulence_cfg, dict):
4807 errors.append(f
" {case_path}: 'models.physics.turbulence' must be a mapping.")
4808 elif isinstance(turbulence_cfg, dict)
and turbulence_cfg:
4811 except ValueError
as e:
4812 errors.append(f
" {case_path}: {e}")
4814 les_cfg = turbulence_cfg.get(
'les')
4815 rans_cfg = turbulence_cfg.get(
'rans')
4816 wall_cfg = turbulence_cfg.get(
'wall_function')
4818 if isinstance(les_cfg, dict):
4819 for key
in (
'enabled',):
4820 if key
in les_cfg
and not isinstance(les_cfg[key], bool):
4821 errors.append(f
" {case_path}: models.physics.turbulence.les.{key} must be true or false.")
4822 for key
in (
'constant_cs',
'max_cs'):
4825 value = float(les_cfg[key])
4827 errors.append(f
" {case_path}: models.physics.turbulence.les.{key} must be nonnegative.")
4828 except (TypeError, ValueError):
4829 errors.append(f
" {case_path}: models.physics.turbulence.les.{key} must be numeric.")
4830 if 'dynamic_frequency' in les_cfg:
4832 value = int(les_cfg[
'dynamic_frequency'])
4834 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
4835 except (TypeError, ValueError):
4836 errors.append(f
" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
4838 if isinstance(rans_cfg, dict):
4839 if 'enabled' in rans_cfg
and not isinstance(rans_cfg[
'enabled'], bool):
4840 errors.append(f
" {case_path}: models.physics.turbulence.rans.enabled must be true or false.")
4842 rans_enabled = bool(rans_cfg.get(
'enabled',
True))
and normalize_rans_model(rans_cfg.get(
'model',
'k_omega')) != 0
4844 rans_enabled =
False
4847 f
"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
4851 f
"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
4854 if isinstance(wall_cfg, dict):
4855 if 'enabled' in wall_cfg
and not isinstance(wall_cfg[
'enabled'], bool):
4856 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
4857 if 'roughness_height' in wall_cfg:
4859 value = float(wall_cfg[
'roughness_height'])
4861 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
4862 except (TypeError, ValueError):
4863 errors.append(f
" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
4866 if not isinstance(solver_cfg, dict)
or not solver_cfg:
4867 errors.append(f
" {solver_path}: solver config is empty or not a valid YAML mapping.")
4869 strategy_cfg = solver_cfg.get(
'strategy', {})
4870 if not isinstance(strategy_cfg, dict):
4871 errors.append(f
" {solver_path}: 'strategy' must be a mapping.")
4872 elif 'implicit' in strategy_cfg:
4874 f
" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
4875 "Use 'strategy.momentum_solver' with named solver values."
4877 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
4880 except ValueError
as e:
4881 errors.append(f
" {solver_path}: {e}")
4883 op_mode_cfg = solver_cfg.get(
'operation_mode', {})
4884 if op_mode_cfg
is not None and not isinstance(op_mode_cfg, dict):
4885 errors.append(f
" {solver_path}: 'operation_mode' must be a mapping when provided.")
4886 elif isinstance(op_mode_cfg, dict):
4887 eulerian_source_mode =
None
4888 normalized_analytical_type =
None
4889 if 'eulerian_field_source' in op_mode_cfg:
4892 except ValueError
as e:
4893 errors.append(f
" {solver_path}: {e}")
4895 analytical_type = op_mode_cfg.get(
'analytical_type')
4896 if analytical_type
is not None:
4899 except ValueError
as e:
4900 errors.append(f
" {solver_path}: {e}")
4902 uniform_flow_cfg = op_mode_cfg.get(
'uniform_flow')
4903 if uniform_flow_cfg
is not None and not isinstance(uniform_flow_cfg, dict):
4904 errors.append(f
" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
4905 elif normalized_analytical_type ==
"UNIFORM_FLOW":
4906 if not isinstance(uniform_flow_cfg, dict):
4908 f
" {solver_path}: operation_mode.uniform_flow is required when "
4909 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
4912 for coord
in (
"u",
"v",
"w"):
4913 if coord
not in uniform_flow_cfg:
4915 f
" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
4919 float(uniform_flow_cfg[coord])
4920 except (TypeError, ValueError):
4922 f
" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
4924 elif uniform_flow_cfg
is not None:
4926 f
" {solver_path}: operation_mode.uniform_flow is only valid when "
4927 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
4930 if eulerian_source_mode ==
"analytical":
4931 effective_analytical_type = normalized_analytical_type
or "TGV3D"
4932 if effective_analytical_type ==
"TGV3D":
4933 if grid_mode !=
'programmatic_c':
4935 f
" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
4936 "'programmatic_c'. File-backed analytical ingestion is only supported for "
4937 "ZERO_FLOW and UNIFORM_FLOW."
4939 elif isinstance(grid_cfg.get(
'programmatic_settings'), dict):
4940 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
4943 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
4944 f
"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
4947 if grid_mode
not in {
'programmatic_c',
'file'}:
4949 f
" {case_path}: grid.mode '{grid_mode}' is not supported when "
4950 f
"operation_mode.analytical_type is '{effective_analytical_type}'. "
4951 "Use 'programmatic_c' or 'file'."
4953 elif grid_mode ==
'programmatic_c' and isinstance(grid_cfg.get(
'programmatic_settings'), dict):
4954 missing_dims = [key
for key
in (
'im',
'jm',
'km')
if key
not in grid_cfg[
'programmatic_settings']]
4957 f
" {case_path}: grid.programmatic_settings must include {missing_dims} when "
4958 f
"operation_mode.analytical_type is '{effective_analytical_type}' and "
4959 "grid.mode is 'programmatic_c'."
4962 verification_cfg = solver_cfg.get(
'verification', {})
4963 if verification_cfg
is not None and not isinstance(verification_cfg, dict):
4964 errors.append(f
" {solver_path}: 'verification' must be a mapping when provided.")
4965 elif isinstance(verification_cfg, dict)
and verification_cfg:
4966 sources_cfg = verification_cfg.get(
'sources', {})
4967 if sources_cfg
is not None and not isinstance(sources_cfg, dict):
4968 errors.append(f
" {solver_path}: 'verification.sources' must be a mapping when provided.")
4969 elif isinstance(sources_cfg, dict)
and sources_cfg:
4970 diff_cfg = sources_cfg.get(
'diffusivity')
4971 scalar_cfg = sources_cfg.get(
'scalar')
4973 if diff_cfg
is not None:
4974 if not isinstance(diff_cfg, dict):
4975 errors.append(f
" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
4977 if eulerian_source_mode !=
"analytical":
4979 f
" {solver_path}: verification.sources.diffusivity is only valid when "
4980 "operation_mode.eulerian_field_source is 'analytical'."
4982 mode = diff_cfg.get(
'mode')
4983 profile = diff_cfg.get(
'profile')
4984 if str(mode).strip().lower() !=
"analytical":
4986 f
" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
4988 if str(profile).strip().upper() !=
"LINEAR_X":
4990 f
" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
4992 for key
in (
"gamma0",
"slope_x"):
4993 if key
not in diff_cfg:
4995 f
" {solver_path}: verification.sources.diffusivity.{key} is required."
4999 float(diff_cfg[key])
5000 except (TypeError, ValueError):
5002 f
" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
5005 if scalar_cfg
is not None:
5006 if not isinstance(scalar_cfg, dict):
5007 errors.append(f
" {solver_path}: 'verification.sources.scalar' must be a mapping.")
5009 if eulerian_source_mode !=
"analytical":
5011 f
" {solver_path}: verification.sources.scalar is only valid when "
5012 "operation_mode.eulerian_field_source is 'analytical'."
5014 mode = scalar_cfg.get(
'mode')
5015 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
5016 if str(mode).strip().lower() !=
"analytical":
5018 f
" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
5020 if profile
not in {
"CONSTANT",
"LINEAR_X",
"SIN_PRODUCT"}:
5022 f
" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
5024 required_scalar_keys = {
5025 "CONSTANT": (
"value",),
5026 "LINEAR_X": (
"phi0",
"slope_x"),
5027 "SIN_PRODUCT": (
"amplitude",
"kx",
"ky",
"kz"),
5029 for key
in required_scalar_keys:
5030 if key
not in scalar_cfg:
5032 f
" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
5036 float(scalar_cfg[key])
5037 except (TypeError, ValueError):
5039 f
" {solver_path}: verification.sources.scalar.{key} must be numeric."
5042 unknown_source_keys = sorted(set(sources_cfg.keys()) - {
"diffusivity",
"scalar"})
5043 if unknown_source_keys:
5045 f
" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
5046 "Currently supported: 'diffusivity', 'scalar'."
5048 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {
"sources"})
5049 if unknown_verification_keys:
5051 f
" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
5052 "Currently supported: 'sources'."
5055 transport_cfg = solver_cfg.get(
'scalar_transport', {})
5056 if transport_cfg
is not None and not isinstance(transport_cfg, dict):
5057 errors.append(f
" {solver_path}: 'scalar_transport' must be a mapping when provided.")
5058 elif isinstance(transport_cfg, dict):
5059 unknown_transport_keys = sorted(set(transport_cfg.keys()) - {
"schmidt_number",
"turbulent_schmidt_number"})
5060 if unknown_transport_keys:
5062 f
" {solver_path}: unsupported scalar_transport entries: {unknown_transport_keys}. "
5063 "Currently supported: 'schmidt_number', 'turbulent_schmidt_number'."
5065 for key
in (
"schmidt_number",
"turbulent_schmidt_number"):
5066 if key
in transport_cfg:
5068 value = float(transport_cfg[key])
5070 errors.append(f
" {solver_path}: scalar_transport.{key} must be positive.")
5071 except (TypeError, ValueError):
5072 errors.append(f
" {solver_path}: scalar_transport.{key} must be numeric.")
5074 tolerances_cfg = solver_cfg.get(
'tolerances', {})
5075 if tolerances_cfg
is not None and not isinstance(tolerances_cfg, dict):
5076 errors.append(f
" {solver_path}: 'tolerances' must be a mapping when provided.")
5077 elif isinstance(tolerances_cfg, dict):
5078 for key
in (
"absolute_tol",
"relative_tol",
"residual_absolute_tol",
"residual_relative_tol"):
5079 if key
in tolerances_cfg:
5081 float(tolerances_cfg[key])
5082 except (TypeError, ValueError):
5083 errors.append(f
" {solver_path}: tolerances.{key} must be numeric.")
5085 ms_cfg = solver_cfg.get(
'momentum_solver', {})
5086 if ms_cfg
is not None and not isinstance(ms_cfg, dict):
5087 errors.append(f
" {solver_path}: 'momentum_solver' must be a mapping when provided.")
5088 elif isinstance(ms_cfg, dict):
5089 unsupported_flat_keys = {
5090 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
5091 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
5092 'rk4_residual_noise_allowance_factor'
5094 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
5095 if present_unsupported:
5097 f
" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
5098 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
5101 allowed_ms_keys = {
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4'}
5102 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
5105 f
" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
5106 "Currently supported: 'dual_time_picard_jameson_rk'."
5108 if 'dual_time_picard_jameson_rk' in ms_cfg
and 'dual_time_picard_rk4' in ms_cfg:
5110 f
" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
5111 "do not also set its deprecated dual_time_picard_rk4 alias."
5114 selected_solver =
None
5115 if isinstance(strategy_cfg, dict)
and 'momentum_solver' in strategy_cfg:
5120 if selected_solver
is None:
5121 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
5123 has_dualtime_block = (
5124 'dual_time_picard_jameson_rk' in ms_cfg
or 'dual_time_picard_rk4' in ms_cfg
5126 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
5128 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
5129 f
"{selected_solver}. Solver-specific blocks must match the selected solver."
5132 dt_picard_cfg = ms_cfg.get(
'dual_time_picard_jameson_rk', ms_cfg.get(
'dual_time_picard_rk4'))
5133 if dt_picard_cfg
is not None:
5134 if not isinstance(dt_picard_cfg, dict):
5135 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
5138 'max_pseudo_steps',
'absolute_tol',
'relative_tol',
'step_tol',
5139 'pseudo_cfl',
'jameson_residual_noise_allowance_factor',
5140 'rk4_residual_noise_allowance_factor'
5142 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
5145 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
5147 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
5148 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
5150 f
" {solver_path}: use only jameson_residual_noise_allowance_factor; "
5151 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
5153 if 'pseudo_cfl' in dt_picard_cfg:
5154 pcfl_cfg = dt_picard_cfg[
'pseudo_cfl']
5155 if not isinstance(pcfl_cfg, dict):
5156 errors.append(f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
5158 allowed_pcfl_keys = {
'initial',
'minimum',
'maximum',
'growth_factor',
'reduction_factor'}
5159 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
5160 if unknown_pcfl_keys:
5162 f
" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
5165 for key
in allowed_pcfl_keys:
5168 numeric_pcfl[key] = float(pcfl_cfg[key])
5169 except (TypeError, ValueError):
5171 f
" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
5173 if numeric_pcfl.get(
'minimum', 1.0) <= 0.0:
5174 errors.append(f
" {solver_path}: pseudo_cfl.minimum must be positive.")
5175 if numeric_pcfl.get(
'growth_factor', 1.0) < 1.0:
5176 errors.append(f
" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
5177 reduction = numeric_pcfl.get(
'reduction_factor', 1.0)
5178 if reduction <= 0.0
or reduction >= 1.0:
5179 errors.append(f
" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
5180 if all(key
in numeric_pcfl
for key
in (
'minimum',
'initial',
'maximum')):
5181 if not numeric_pcfl[
'minimum'] <= numeric_pcfl[
'initial'] <= numeric_pcfl[
'maximum']:
5182 errors.append(f
" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
5184 'jameson_residual_noise_allowance_factor'
5185 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
5186 else 'rk4_residual_noise_allowance_factor'
5188 if noise_key
in dt_picard_cfg:
5190 if float(dt_picard_cfg[noise_key]) < 1.0:
5191 errors.append(f
" {solver_path}: {noise_key} must be at least 1.")
5192 except (TypeError, ValueError):
5193 errors.append(f
" {solver_path}: {noise_key} must be numeric.")
5195 solution_convergence_cfg = solver_cfg.get(
'solution_convergence', {})
5196 if solution_convergence_cfg
is not None and not isinstance(solution_convergence_cfg, dict):
5197 errors.append(f
" {solver_path}: 'solution_convergence' must be a mapping when provided.")
5198 elif isinstance(solution_convergence_cfg, dict)
and solution_convergence_cfg:
5199 allowed_solution_convergence_keys = {
5200 'enabled',
'mode',
'periodic_deterministic',
'statistical_steady'
5202 unknown_solution_convergence_keys = sorted(set(solution_convergence_cfg.keys()) - allowed_solution_convergence_keys)
5203 if unknown_solution_convergence_keys:
5205 f
" {solver_path}: unsupported solution_convergence keys: {unknown_solution_convergence_keys}."
5208 mode = solution_convergence_cfg.get(
'mode',
'steady_deterministic')
5211 except ValueError
as e:
5212 errors.append(f
" {solver_path}: {e}")
5213 normalized_solution_mode =
None
5215 periodic_cfg = solution_convergence_cfg.get(
'periodic_deterministic')
5216 statistical_cfg = solution_convergence_cfg.get(
'statistical_steady')
5217 if periodic_cfg
is not None and not isinstance(periodic_cfg, dict):
5218 errors.append(f
" {solver_path}: solution_convergence.periodic_deterministic must be a mapping when provided.")
5219 if statistical_cfg
is not None and not isinstance(statistical_cfg, dict):
5220 errors.append(f
" {solver_path}: solution_convergence.statistical_steady must be a mapping when provided.")
5222 if isinstance(periodic_cfg, dict):
5223 unknown_periodic_keys = sorted(set(periodic_cfg.keys()) - {
'period_steps'})
5224 if unknown_periodic_keys:
5226 f
" {solver_path}: unsupported keys in solution_convergence.periodic_deterministic: {unknown_periodic_keys}."
5228 period_steps = periodic_cfg.get(
'period_steps')
5229 if period_steps
is not None and (
not isinstance(period_steps, int)
or period_steps <= 0):
5230 errors.append(f
" {solver_path}: solution_convergence.periodic_deterministic.period_steps must be a positive integer.")
5232 if isinstance(statistical_cfg, dict):
5233 unknown_statistical_keys = sorted(set(statistical_cfg.keys()) - {
'window_steps'})
5234 if unknown_statistical_keys:
5236 f
" {solver_path}: unsupported keys in solution_convergence.statistical_steady: {unknown_statistical_keys}."
5238 window_steps = statistical_cfg.get(
'window_steps')
5239 if window_steps
is not None and (
not isinstance(window_steps, int)
or window_steps <= 0):
5240 errors.append(f
" {solver_path}: solution_convergence.statistical_steady.window_steps must be a positive integer.")
5242 if normalized_solution_mode ==
"PERIODIC_DETERMINISTIC":
5243 if not isinstance(periodic_cfg, dict)
or 'period_steps' not in periodic_cfg:
5245 f
" {solver_path}: solution_convergence.periodic_deterministic.period_steps is required when mode is 'periodic_deterministic'."
5247 elif periodic_cfg
is not None:
5249 f
" {solver_path}: solution_convergence.periodic_deterministic is only valid when mode is 'periodic_deterministic'."
5252 if normalized_solution_mode ==
"STATISTICAL_STEADY":
5253 if not isinstance(statistical_cfg, dict)
or 'window_steps' not in statistical_cfg:
5255 f
" {solver_path}: solution_convergence.statistical_steady.window_steps is required when mode is 'statistical_steady'."
5257 elif statistical_cfg
is not None:
5259 f
" {solver_path}: solution_convergence.statistical_steady is only valid when mode is 'statistical_steady'."
5263 interp_cfg = solver_cfg.get(
'interpolation', {})
if isinstance(solver_cfg, dict)
else {}
5264 if interp_cfg
is not None and not isinstance(interp_cfg, dict):
5265 errors.append(f
" {solver_path}: 'interpolation' must be a mapping when provided.")
5266 elif isinstance(interp_cfg, dict)
and 'method' in interp_cfg:
5269 except ValueError
as e:
5270 errors.append(f
" {solver_path}: {e}")
5273 if not isinstance(monitor_cfg, dict)
or not monitor_cfg:
5274 errors.append(f
" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
5276 io_cfg = monitor_cfg.get(
'io', {})
5277 freq = io_cfg.get(
'data_output_frequency')
5278 if freq
is not None and (
not isinstance(freq, int)
or freq <= 0):
5279 errors.append(f
" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
5280 particle_console_freq = io_cfg.get(
'particle_console_output_frequency')
5281 if particle_console_freq
is not None and (
not isinstance(particle_console_freq, int)
or particle_console_freq < 0):
5283 f
" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
5284 f
"(got {particle_console_freq})."
5288 except ValueError
as e:
5289 errors.append(f
" {monitor_path}: {e}")
5292 except ValueError
as e:
5293 errors.append(f
" {monitor_path}: {e}")
5296 except ValueError
as e:
5297 errors.append(f
" {monitor_path}: {e}")
5302 f
"{case_path}: This configuration requires restart data (start_step > 0, "
5303 "eulerian_field_source='load', or particle restart_mode='load'). "
5304 "Use --restart-from or --continue when running."
5309 for warning
in warnings:
5310 print(f
"[WARN] {warning}", file=sys.stderr)
5315 @brief Validates the post-processing config before running the post-processor.
5316 @param[in] post_cfg Parsed post-processing YAML dictionary.
5317 @param[in] post_path Path to post file (for error messages).
5318 @throws SystemExit on validation failure.
5324 if not isinstance(post_cfg, dict)
or not post_cfg:
5325 errors.append(f
" {post_path}: post-processing config is empty or not a valid YAML mapping.")
5329 if 'run_control' not in post_cfg:
5330 errors.append(f
" {post_path}: missing required section 'run_control'.")
5332 rc = post_cfg.get(
'run_control', {})
5333 if not isinstance(rc, dict):
5334 errors.append(f
" {post_path}: 'run_control' must be a mapping.")
5336 for canonical_key, aliases
in POST_RUN_CONTROL_ALIASES.items():
5337 if not any(alias
in rc
for alias
in aliases):
5338 alias_list =
"', '".join(aliases)
5340 f
" {post_path}: missing required key 'run_control.{canonical_key}' "
5341 f
"(accepted aliases: '{alias_list}')."
5347 except (TypeError, ValueError):
5348 alias_name = next((alias
for alias
in aliases
if alias
in rc), canonical_key)
5350 f
" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
5354 io_cfg = post_cfg.get(
'io', {})
5355 source_cfg = post_cfg.get(
'source_data')
5356 if source_cfg
is not None and not isinstance(source_cfg, dict):
5357 errors.append(f
" {post_path}: 'source_data' must be a mapping when provided.")
5358 global_ops = post_cfg.get(
'global_operations')
5359 if global_ops
is not None:
5360 if not isinstance(global_ops, dict):
5361 errors.append(f
" {post_path}: 'global_operations' must be a mapping when provided.")
5362 elif 'dimensionalize' in global_ops
and not isinstance(global_ops.get(
'dimensionalize'), bool):
5363 errors.append(f
" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
5365 errors.append(f
" {post_path}: missing required section 'io'.")
5366 elif not isinstance(io_cfg, dict):
5367 errors.append(f
" {post_path}: 'io' must be a mapping.")
5369 for k
in [
'output_directory',
'output_filename_prefix']:
5371 errors.append(f
" {post_path}: missing required key 'io.{k}'.")
5372 for key_name
in (
'output_directory',
'output_filename_prefix',
'particle_filename_prefix'):
5373 if key_name
in io_cfg
and not isinstance(io_cfg.get(key_name), str):
5374 errors.append(f
" {post_path}: 'io.{key_name}' must be a string when provided.")
5375 if 'output_particles' in io_cfg
and not isinstance(io_cfg.get(
'output_particles'), bool):
5376 errors.append(f
" {post_path}: 'io.output_particles' must be a boolean when provided.")
5377 particle_subsampling_frequency = io_cfg.get(
'particle_subsampling_frequency')
5378 if particle_subsampling_frequency
is not None:
5379 if not isinstance(particle_subsampling_frequency, int)
or particle_subsampling_frequency <= 0:
5381 f
" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
5383 input_extensions = io_cfg.get(
'input_extensions')
5385 if input_extensions
is not None:
5386 if not isinstance(input_extensions, dict):
5387 errors.append(f
" {post_path}: 'io.input_extensions' must be a mapping when provided.")
5389 for ext_key
in (
'eulerian',
'particle'):
5390 ext_val = input_extensions.get(ext_key)
5391 if ext_val
is not None and not isinstance(ext_val, str):
5392 errors.append(f
" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
5393 if source_input_extensions
is not None:
5394 if not isinstance(source_input_extensions, dict):
5395 errors.append(f
" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
5397 for ext_key
in (
'eulerian',
'particle'):
5398 ext_val = source_input_extensions.get(ext_key)
5399 if ext_val
is not None and not isinstance(ext_val, str):
5401 f
" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
5404 averaged_fields = io_cfg.get(
'eulerian_fields_averaged')
5405 if averaged_fields
is not None and not isinstance(averaged_fields, list):
5406 errors.append(f
" {post_path}: 'io.eulerian_fields_averaged' must be a list when provided.")
5407 for list_key
in (
'eulerian_fields',
'particle_fields'):
5408 list_val = io_cfg.get(list_key)
5409 if list_val
is not None and not isinstance(list_val, list):
5410 errors.append(f
" {post_path}: 'io.{list_key}' must be a list when provided.")
5413 eulerian_pipeline = post_cfg.get(
'eulerian_pipeline', [])
5414 if eulerian_pipeline
is not None and not isinstance(eulerian_pipeline, list):
5415 errors.append(f
" {post_path}: 'eulerian_pipeline' must be a list when provided.")
5416 eulerian_pipeline = []
5417 for i, entry
in enumerate(eulerian_pipeline):
5418 if not isinstance(entry, dict)
or 'task' not in entry:
5419 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
5420 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
5422 task_name = entry.get(
'task')
5423 if task_name ==
'q_criterion':
5425 if task_name ==
'nodal_average':
5426 in_field = entry.get(
'input_field')
5427 out_field = entry.get(
'output_field')
5428 if not isinstance(in_field, str)
or not in_field.strip():
5429 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
5430 if not isinstance(out_field, str)
or not out_field.strip():
5431 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
5432 if isinstance(in_field, str)
and isinstance(out_field, str)
and in_field == out_field:
5434 f
" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
5437 if task_name ==
'normalize_field':
5438 field = entry.get(
'field',
'P')
5439 if not isinstance(field, str)
or not field.strip():
5440 errors.append(f
" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
5443 f
" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
5446 reference_point = entry.get(
'reference_point', [1, 1, 1])
5447 if not isinstance(reference_point, (list, tuple))
or len(reference_point) != 3:
5449 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
5452 for rp_idx, coord
in enumerate(reference_point):
5455 except (TypeError, ValueError):
5457 f
" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
5458 "must be integer-compatible."
5462 f
" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]."
5466 lagrangian_pipeline = post_cfg.get(
'lagrangian_pipeline', [])
5467 if lagrangian_pipeline
is not None and not isinstance(lagrangian_pipeline, list):
5468 errors.append(f
" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
5469 lagrangian_pipeline = []
5470 for i, entry
in enumerate(lagrangian_pipeline):
5471 if not isinstance(entry, dict)
or 'task' not in entry:
5472 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
5474 task_name = entry.get(
'task')
5475 if task_name ==
'specific_ke':
5476 in_field = entry.get(
'input_field')
5477 out_field = entry.get(
'output_field')
5478 if not isinstance(in_field, str)
or not in_field.strip():
5479 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
5480 if not isinstance(out_field, str)
or not out_field.strip():
5481 errors.append(f
" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
5484 f
" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
5488 stats_cfg = post_cfg.get(
'statistics_pipeline')
5490 if stats_cfg
is not None:
5491 if isinstance(stats_cfg, list):
5492 stats_entries = stats_cfg
5493 elif isinstance(stats_cfg, dict):
5494 stats_entries = stats_cfg.get(
'tasks', [])
5495 if not isinstance(stats_entries, list):
5496 errors.append(f
" {post_path}: 'statistics_pipeline.tasks' must be a list.")
5497 stats_output_prefix = stats_cfg.get(
'output_prefix')
5498 if stats_output_prefix
is not None and not isinstance(stats_output_prefix, str):
5499 errors.append(f
" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
5502 f
" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
5504 for i, entry
in enumerate(stats_entries):
5505 if isinstance(entry, str):
5507 elif isinstance(entry, dict)
and 'task' in entry:
5508 task_name = entry.get(
'task')
5511 f
" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
5516 except ValueError
as e:
5517 errors.append(f
" {post_path}: {e}")
5519 legacy_stats_output_prefix = post_cfg.get(
'statistics_output_prefix')
5520 if legacy_stats_output_prefix
is not None and not isinstance(legacy_stats_output_prefix, str):
5521 errors.append(f
" {post_path}: 'statistics_output_prefix' must be a string when provided.")
5528 @brief Validate Slurm scheduler configuration from cluster.yml.
5529 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
5530 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
5535 if not isinstance(cluster_cfg, dict)
or not cluster_cfg:
5536 errors.append(f
" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
5539 scheduler = cluster_cfg.get(
"scheduler", {})
5540 if not isinstance(scheduler, dict):
5541 errors.append(f
" {cluster_path}: 'scheduler' must be a mapping.")
5543 scheduler_type = scheduler.get(
"type",
"slurm")
5544 if str(scheduler_type).lower() !=
"slurm":
5545 errors.append(f
" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
5547 resources = cluster_cfg.get(
"resources", {})
5548 if not isinstance(resources, dict):
5549 errors.append(f
" {cluster_path}: 'resources' must be a mapping.")
5551 for req
in (
"account",
"nodes",
"ntasks_per_node",
"mem",
"time"):
5552 if req
not in resources:
5553 errors.append(f
" {cluster_path}: missing required key 'resources.{req}'.")
5554 for int_key
in (
"nodes",
"ntasks_per_node"):
5555 if int_key
in resources:
5556 val = resources.get(int_key)
5557 if not isinstance(val, int)
or val <= 0:
5558 errors.append(f
" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
5559 for str_key
in (
"account",
"mem",
"time",
"partition"):
5560 if str_key
in resources
and resources.get(str_key)
is not None:
5561 if not isinstance(resources.get(str_key), str):
5562 errors.append(f
" {cluster_path}: resources.{str_key} must be a string when provided.")
5563 if isinstance(resources.get(
"time"), str):
5566 except ValueError
as exc:
5568 f
" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
5570 account = resources.get(
"account")
5571 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
5573 f
"{cluster_path}: resources.account still uses the sample placeholder "
5574 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
5577 notifications = cluster_cfg.get(
"notifications", {})
5578 if notifications
is not None and not isinstance(notifications, dict):
5579 errors.append(f
" {cluster_path}: 'notifications' must be a mapping when provided.")
5580 elif isinstance(notifications, dict):
5581 mail_user = notifications.get(
"mail_user")
5583 errors.append(f
" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
5584 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
5586 f
"{cluster_path}: notifications.mail_user still uses the sample placeholder "
5587 f
"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
5589 mail_type = notifications.get(
"mail_type")
5590 if mail_type
is not None and not isinstance(mail_type, str):
5591 errors.append(f
" {cluster_path}: notifications.mail_type must be a string when provided.")
5593 execution = cluster_cfg.get(
"execution", {})
5594 if execution
is not None and not isinstance(execution, dict):
5595 errors.append(f
" {cluster_path}: 'execution' must be a mapping when provided.")
5596 elif isinstance(execution, dict):
5597 module_setup = execution.get(
"module_setup", [])
5598 if module_setup
is not None and not isinstance(module_setup, list):
5599 errors.append(f
" {cluster_path}: execution.module_setup must be a list of shell lines.")
5600 elif isinstance(module_setup, list):
5601 for i, line
in enumerate(module_setup):
5602 if not isinstance(line, str):
5603 errors.append(f
" {cluster_path}: execution.module_setup[{i}] must be a string.")
5605 launcher = execution.get(
"launcher")
5606 if launcher
is not None and not isinstance(launcher, str):
5607 errors.append(f
" {cluster_path}: execution.launcher must be a string when provided.")
5608 launcher_args = execution.get(
"launcher_args")
5609 if launcher_args
is not None and not isinstance(launcher_args, list):
5610 errors.append(f
" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
5611 elif isinstance(launcher_args, list):
5612 for i, token
in enumerate(launcher_args):
5613 if not isinstance(token, (str, int, float)):
5614 errors.append(f
" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
5617 f
" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
5618 "split whitespace-separated arguments into separate list items."
5620 if (launcher
is None or isinstance(launcher, str))
and (launcher_args
is None or isinstance(launcher_args, list)):
5623 except ValueError
as exc:
5624 errors.append(f
" {cluster_path}: {exc}.")
5626 extra_sbatch = execution.get(
"extra_sbatch")
5627 if extra_sbatch
is not None and not isinstance(extra_sbatch, (dict, list)):
5628 errors.append(f
" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
5630 walltime_guard = execution.get(
"walltime_guard")
5631 if walltime_guard
is not None and not isinstance(walltime_guard, dict):
5632 errors.append(f
" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
5633 elif isinstance(walltime_guard, dict):
5634 enabled = walltime_guard.get(
"enabled")
5635 if enabled
is not None and not isinstance(enabled, bool):
5636 errors.append(f
" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
5638 warmup_steps = walltime_guard.get(
"warmup_steps")
5639 if warmup_steps
is not None and (
not isinstance(warmup_steps, int)
or isinstance(warmup_steps, bool)
or warmup_steps <= 0):
5641 f
" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
5644 multiplier = walltime_guard.get(
"multiplier")
5645 if multiplier
is not None:
5646 if isinstance(multiplier, bool)
or not isinstance(multiplier, (int, float))
or multiplier <= 0.0:
5648 f
" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
5650 elif float(multiplier) > 5.0:
5652 f
" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
5655 min_seconds = walltime_guard.get(
"min_seconds")
5656 if min_seconds
is not None and (
5657 isinstance(min_seconds, bool)
or not isinstance(min_seconds, (int, float))
or float(min_seconds) <= 0.0
5660 f
" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
5663 estimator_alpha = walltime_guard.get(
"estimator_alpha")
5664 if estimator_alpha
is not None:
5665 if isinstance(estimator_alpha, bool)
or not isinstance(estimator_alpha, (int, float)):
5667 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
5669 elif float(estimator_alpha) <= 0.0
or float(estimator_alpha) > 1.0:
5671 f
" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
5675 for warning
in warnings:
5676 print(f
"[WARN] {warning}", file=sys.stderr)
5683 @brief Validate sweep/study specification from study.yml.
5684 @param[in] study_cfg Argument passed to `validate_study_config()`.
5685 @param[in] study_path Argument passed to `validate_study_config()`.
5686 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
5690 if not isinstance(study_cfg, dict)
or not study_cfg:
5691 errors.append(f
" {study_path}: study config is empty or not a valid YAML mapping.")
5694 base_cfgs = study_cfg.get(
"base_configs")
5695 if not isinstance(base_cfgs, dict):
5696 errors.append(f
" {study_path}: missing required mapping 'base_configs'.")
5698 for req
in (
"case",
"solver",
"monitor",
"post"):
5699 path_val = base_cfgs.get(req)
5700 if not path_val
or not isinstance(path_val, str):
5701 errors.append(f
" {study_path}: base_configs.{req} must be a path string.")
5702 elif not skip_base_file_check:
5704 if not os.path.isfile(resolved):
5705 errors.append(f
" {study_path}: base_configs.{req} does not exist: {resolved}")
5707 study_type = study_cfg.get(
"study_type")
5708 allowed_types = {
"grid_independence",
"timestep_independence",
"sensitivity"}
5709 if study_type
not in allowed_types:
5711 f
" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
5714 parameters = study_cfg.get(
"parameters")
5715 parameter_sets = study_cfg.get(
"parameter_sets")
5716 allowed_roots = {
"case",
"solver",
"monitor",
"post"}
5717 if bool(parameters) == bool(parameter_sets):
5718 errors.append(f
" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
5719 elif parameter_sets:
5720 if not isinstance(parameter_sets, list)
or not parameter_sets:
5721 errors.append(f
" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
5723 for set_index, param_set
in enumerate(parameter_sets):
5724 if not isinstance(param_set, dict)
or not param_set:
5726 f
" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
5729 for key, value
in param_set.items():
5730 if not isinstance(key, str)
or "." not in key:
5732 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
5735 root = key.split(
".", 1)[0]
5736 if root
not in allowed_roots:
5738 f
" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
5740 if isinstance(value, (dict, list)):
5742 f
" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
5745 if not isinstance(parameters, dict)
or not parameters:
5746 errors.append(f
" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
5748 for key, values
in parameters.items():
5749 if not isinstance(key, str)
or "." not in key:
5751 f
" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
5754 root = key.split(
".", 1)[0]
5755 if root
not in allowed_roots:
5757 f
" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
5759 if not isinstance(values, list)
or len(values) == 0:
5760 errors.append(f
" {study_path}: parameters.{key} must be a non-empty list.")
5762 metrics = study_cfg.get(
"metrics", [])
5763 if metrics
is not None and not isinstance(metrics, list):
5764 errors.append(f
" {study_path}: 'metrics' must be a list when provided.")
5765 elif isinstance(metrics, list):
5766 for i, metric
in enumerate(metrics):
5767 if isinstance(metric, str):
5769 if not isinstance(metric, dict):
5771 f
" {study_path}: metrics[{i}] must be a string or mapping."
5774 if "name" not in metric:
5775 errors.append(f
" {study_path}: metrics[{i}] missing required key 'name'.")
5776 if "source" not in metric:
5777 errors.append(f
" {study_path}: metrics[{i}] missing required key 'source'.")
5779 plotting = study_cfg.get(
"plotting", {})
5780 if plotting
is not None and not isinstance(plotting, dict):
5781 errors.append(f
" {study_path}: 'plotting' must be a mapping when provided.")
5782 elif isinstance(plotting, dict):
5783 enabled = plotting.get(
"enabled")
5784 if enabled
is not None and not isinstance(enabled, bool):
5785 errors.append(f
" {study_path}: plotting.enabled must be boolean when provided.")
5786 output_format = plotting.get(
"output_format")
5787 if output_format
is not None and output_format
not in {
"png",
"pdf",
"svg"}:
5788 errors.append(f
" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
5790 execution = study_cfg.get(
"execution", {})
5791 if execution
is not None and not isinstance(execution, dict):
5792 errors.append(f
" {study_path}: 'execution' must be a mapping when provided.")
5793 elif isinstance(execution, dict):
5794 max_conc = execution.get(
"max_concurrent_array_tasks")
5795 if max_conc
is not None and (
not isinstance(max_conc, int)
or max_conc <= 0):
5797 f
" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
5805 @brief Set nested dictionary value, creating intermediate maps when needed.
5806 @param[in] container Argument passed to `_deep_set()`.
5807 @param[in] dotted_path Argument passed to `_deep_set()`.
5808 @param[in] value Argument passed to `_deep_set()`.
5810 keys = dotted_path.split(
".")
5812 for key
in keys[:-1]:
5813 if key
not in current
or not isinstance(current[key], dict):
5815 current = current[key]
5816 current[keys[-1]] = value
5820 @brief Expand study parameter lists into cartesian-product combinations.
5821 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
5822 @return Value returned by `expand_parameter_matrix()`.
5824 param_keys =
list(parameters.keys())
5825 all_values = [parameters[k]
for k
in param_keys]
5827 for combo
in itertools.product(*all_values):
5828 combos.append(dict(zip(param_keys, combo)))
5834 @brief Expand either cartesian-study parameters or explicit parameter sets.
5835 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
5836 @return Value returned by `expand_study_parameter_combinations()`.
5838 parameter_sets = study_cfg.get(
"parameter_sets")
5840 return [dict(param_set)
for param_set
in parameter_sets]
5846 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
5847 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
5848 @return Value returned by `get_study_parameter_keys()`.
5850 parameters = study_cfg.get(
"parameters")
5851 if isinstance(parameters, dict)
and parameters:
5852 return list(parameters.keys())
5855 parameter_sets = study_cfg.get(
"parameter_sets")
or []
5856 for param_set
in parameter_sets:
5857 if not isinstance(param_set, dict):
5859 for key
in param_set.keys():
5867 @brief Return cluster total tasks.
5868 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
5869 @return Value returned by `get_cluster_total_tasks()`.
5871 resources = cluster_cfg.get(
"resources", {})
5872 return int(resources.get(
"nodes", 1)) * int(resources.get(
"ntasks_per_node", 1))
5876 @brief Normalize extension.
5877 @param[in] ext Argument passed to `normalize_extension()`.
5878 @return Value returned by `normalize_extension()`.
5882 return str(ext).strip().lstrip(
".")
5891 stderr_path: str =
None,
5892 env_vars: dict =
None,
5893 shell_env_vars: dict =
None,
5894 array_spec: str =
None
5897 @brief Render a Slurm batch script for a single command.
5898 @param[in] script_path Argument passed to `render_slurm_script()`.
5899 @param[in] job_name Argument passed to `render_slurm_script()`.
5900 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
5901 @param[in] command Argument passed to `render_slurm_script()`.
5902 @param[in] workdir Argument passed to `render_slurm_script()`.
5903 @param[in] stdout_path Argument passed to `render_slurm_script()`.
5904 @param[in] stderr_path Argument passed to `render_slurm_script()`.
5905 @param[in] env_vars Argument passed to `render_slurm_script()`.
5906 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
5907 @param[in] array_spec Argument passed to `render_slurm_script()`.
5909 resources = cluster_cfg.get(
"resources", {})
5910 notifications = cluster_cfg.get(
"notifications", {})
or {}
5911 execution = cluster_cfg.get(
"execution", {})
or {}
5912 extra_sbatch = execution.get(
"extra_sbatch")
5913 module_setup = execution.get(
"module_setup", [])
or []
5915 if stderr_path
is None:
5916 stderr_path = stdout_path.replace(
".out",
".err")
5920 f
"#SBATCH --job-name={job_name}",
5921 f
"#SBATCH --nodes={resources['nodes']}",
5922 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
5923 f
"#SBATCH --mem={resources['mem']}",
5924 f
"#SBATCH --time={resources['time']}",
5925 f
"#SBATCH --output={stdout_path}",
5926 f
"#SBATCH --error={stderr_path}",
5927 f
"#SBATCH --account={resources['account']}",
5929 partition = resources.get(
"partition")
5931 lines.append(f
"#SBATCH --partition={partition}")
5933 lines.append(f
"#SBATCH --array={array_spec}")
5934 mail_user = notifications.get(
"mail_user")
5935 mail_type = notifications.get(
"mail_type")
5937 lines.append(f
"#SBATCH --mail-user={mail_user}")
5939 lines.append(f
"#SBATCH --mail-type={mail_type}")
5941 if isinstance(extra_sbatch, dict):
5942 for key, value
in extra_sbatch.items():
5944 if not flag.startswith(
"--"):
5946 if isinstance(value, bool):
5948 lines.append(f
"#SBATCH {flag}")
5949 elif value
is not None:
5950 lines.append(f
"#SBATCH {flag}={value}")
5951 elif isinstance(extra_sbatch, list):
5952 for token
in extra_sbatch:
5953 lines.append(f
"#SBATCH {token}")
5958 "set -euo pipefail",
5960 f
"cd {shlex.quote(workdir)}",
5961 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
5962 'echo "[$(date)] Working directory: $PWD"',
5967 for key, value
in shell_env_vars.items():
5968 lines.append(f
"export {key}={value}")
5970 for setup_line
in module_setup:
5971 lines.append(str(setup_line))
5974 for key, value
in env_vars.items():
5975 lines.append(f
"export {key}={shlex.quote(str(value))}")
5977 cmd =
" ".join(shlex.quote(str(tok))
for tok
in command)
5978 lines.append(f
"exec {cmd}")
5980 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
5981 with open(script_path,
"w")
as f:
5982 f.write(
"\n".join(lines) +
"\n")
5983 os.chmod(script_path, 0o755)
5986 launcher:
"str | None",
5987 launcher_args:
"list | None" =
None,
5988 label: str =
"launcher",
5989) ->
"tuple[str | None, list[str]]":
5991 @brief Canonicalize launcher config into executable token plus argv-style flags.
5992 @param[in] launcher Argument passed to `split_launcher_tokens()`.
5993 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
5994 @param[in] label Argument passed to `split_launcher_tokens()`.
5995 @return Value returned by `split_launcher_tokens()`.
5997 normalized_args = [str(x)
for x
in (launcher_args
or [])]
5999 if launcher
is None:
6000 return None, normalized_args
6003 launcher_tokens = shlex.split(str(launcher))
6004 except ValueError
as exc:
6005 raise ValueError(f
"{label} is not shell-parseable: {exc}")
from exc
6007 if not launcher_tokens:
6008 return None, normalized_args
6010 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
6015 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
6016 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
6017 @return Value returned by `normalize_cluster_launcher()`.
6020 execution.get(
"launcher"),
6021 execution.get(
"launcher_args")
or [],
6022 label=
"execution.launcher",
6028 @brief Remove explicit MPI task-count flags from known launchers.
6029 @param[in] launcher_name Basename-normalized launcher executable.
6030 @param[in] launcher_args Launcher argument list.
6031 @return Filtered launcher arguments with explicit size flags removed.
6035 while idx < len(launcher_args):
6036 token = str(launcher_args[idx])
6038 if launcher_name ==
"srun":
6039 if token
in {
"-n",
"--ntasks"}:
6042 if token.startswith(
"--ntasks="):
6045 elif launcher_name
in {
"mpiexec",
"mpirun"}:
6046 if token
in {
"-n",
"-np"}:
6049 if token.startswith(
"-n=")
or token.startswith(
"-np="):
6053 filtered.append(token)
6061 @brief Clone cluster config and force a single-node post stage task layout.
6062 @param[in] cluster_cfg Base cluster configuration.
6063 @param[in] num_procs Number of post tasks to request.
6064 @return Cluster configuration specialized for the post stage.
6066 post_cluster_cfg = copy.deepcopy(cluster_cfg)
6067 post_cluster_cfg.setdefault(
"resources", {})
6068 post_cluster_cfg[
"resources"][
"nodes"] = 1
6069 post_cluster_cfg[
"resources"][
"ntasks_per_node"] = int(num_procs)
6070 return post_cluster_cfg
6075 executable_args: list,
6077 config_search_anchor: str =
None,
6078 allow_single_rank_launcher_override: bool =
False,
6079 force_num_procs:
"int | None" =
None,
6082 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
6083 @param[in] executable Argument passed to `build_local_launch_command()`.
6084 @param[in] executable_args Argument passed to `build_local_launch_command()`.
6085 @param[in] num_procs Argument passed to `build_local_launch_command()`.
6086 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
6087 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
6088 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6089 @return Value returned by `build_local_launch_command()`.
6091 target_num_procs = force_num_procs
if force_num_procs
is not None else num_procs
6092 command = [executable] + executable_args
6093 if target_num_procs <= 1
and not allow_single_rank_launcher_override:
6096 launcher_override = os.environ.get(
"PICURV_MPI_LAUNCHER")
6097 if launcher_override
is None:
6098 launcher_override = os.environ.get(
"MPI_LAUNCHER")
6101 if launcher_override
is not None:
6102 explicit_launcher_config =
True
6105 label=
"local MPI launcher override",
6110 configured_launcher = local_execution.get(
"launcher")
6111 configured_args = local_execution.get(
"launcher_args")
or []
6112 explicit_launcher_config = configured_launcher
is not None or bool(configured_args)
6113 if target_num_procs <= 1
and not explicit_launcher_config:
6116 configured_launcher
if configured_launcher
is not None else "mpiexec",
6118 label=
"local_execution.launcher",
6120 except ValueError
as exc:
6121 print(f
"[FATAL] {exc}", file=sys.stderr)
6127 launcher_name = os.path.basename(launcher).lower()
6128 if force_num_procs
is not None:
6130 prefix = [launcher] + launcher_args
6132 if launcher_name ==
"srun":
6133 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
6135 prefix += [
"-n", str(target_num_procs)]
6136 elif launcher_name
in {
"mpiexec",
"mpirun"}:
6137 has_n = any(token
in {
"-n",
"-np"}
for token
in launcher_args)
6139 prefix += [
"-n", str(target_num_procs)]
6141 return prefix + command
6145 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
6146 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
6147 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
6148 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
6149 @return Value returned by `resolve_cluster_execution()`.
6153 execution = cluster_cfg.get(
"execution", {})
or {}
6154 cluster_override = {
6155 "launcher": execution.get(
"launcher")
if "launcher" in execution
else None,
6156 "launcher_args": execution.get(
"launcher_args")
if "launcher_args" in execution
else None,
6164 executable_args: list,
6165 config_search_anchor: str =
None,
6166 extra_search_anchors=
None,
6167 force_num_procs:
"int | None" =
None,
6170 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
6171 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
6172 @param[in] executable Argument passed to `build_cluster_launch_command()`.
6173 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
6174 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
6175 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
6176 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6177 @return Value returned by `build_cluster_launch_command()`.
6182 config_search_anchor=config_search_anchor,
6183 extra_search_anchors=extra_search_anchors,
6186 execution.get(
"launcher")
if execution.get(
"launcher")
is not None else "srun",
6187 execution.get(
"launcher_args")
or [],
6188 label=
"cluster execution launcher",
6190 except ValueError
as exc:
6191 print(f
"[FATAL] {exc}", file=sys.stderr)
6195 launcher_name = launcher.lower()
if launcher
else ""
6196 if force_num_procs
is not None:
6199 if launcher
and launcher_name ==
"srun":
6200 has_n = any(token
in {
"-n",
"--ntasks"}
for token
in launcher_args)
6201 cmd = [
"srun"] + launcher_args
6203 cmd += [
"-n", str(ntasks)]
6204 return cmd + [executable] + executable_args
6206 if launcher
and launcher_name ==
"mpirun":
6207 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
6208 cmd = [
"mpirun"] + launcher_args
6210 cmd += [
"-np", str(ntasks)]
6211 return cmd + [executable] + executable_args
6213 if launcher
and launcher_name ==
"mpiexec":
6214 has_np = any(token
in {
"-np",
"-n"}
for token
in launcher_args)
6215 cmd = [
"mpiexec"] + launcher_args
6217 cmd += [
"-np", str(ntasks)]
6218 return cmd + [executable] + executable_args
6223 cmd.append(str(launcher))
6224 cmd += launcher_args
6225 cmd += [executable] + executable_args
6230 @brief Extract numeric job id from standard sbatch output.
6231 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
6232 @return Value returned by `parse_slurm_job_id()`.
6234 match = re.search(
r"Submitted batch job\s+(\d+)", sbatch_output
or "")
6235 return match.group(1)
if match
else None
6237def submit_sbatch(script_path: str, dependency: str =
None, dependency_type: str =
"afterok") -> dict:
6239 @brief Submit sbatch script and return submission metadata.
6240 @param[in] script_path Argument passed to `submit_sbatch()`.
6241 @param[in] dependency Argument passed to `submit_sbatch()`.
6242 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
6243 @return Value returned by `submit_sbatch()`.
6247 cmd.append(f
"--dependency={dependency_type}:{dependency}")
6248 cmd.append(script_path)
6249 result = subprocess.run(cmd, text=
True, capture_output=
True, check=
False)
6252 "returncode": result.returncode,
6253 "stdout": (result.stdout
or "").strip(),
6254 "stderr": (result.stderr
or "").strip(),
6255 "script": script_path,
6257 if result.returncode != 0:
6258 print(f
"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
6259 sys.exit(result.returncode)
6261 if not metadata[
"job_id"]:
6263 f
"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
6272 @brief Prints validation errors and exits.
6273 @param[in] errors List of error message strings.
6275 print(f
"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
6276 for raw_error
in errors:
6282 "\nHint: See examples/master_template/ for valid config structure and "
6283 "docs/pages/14_Config_Contract.md for key-level contract details.",
6291 @brief Creates a standard header block for all generated files.
6292 @param[in] run_id The unique identifier for the current simulation run.
6293 @param[in] source_files A dictionary of source profile files used.
6294 @return A formatted string containing the header.
6297 "# ==============================================================================",
6298 "# AUTO-GENERATED CONFIGURATION FILE",
6299 "# ------------------------------------------------------------------------------",
6300 f
"# Run ID: {run_id}",
6301 f
"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
6303 "# Source Configuration:"
6305 for name, path
in source_files.items():
6306 header_parts.append(f
"# - {name:<12}: {os.path.basename(path)}")
6307 header_parts.extend([
6309 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
6310 "# ==============================================================================\n"
6312 return "\n".join(header_parts)
6316 @brief Generic function to create a file containing a simple list of strings.
6317 @param[in] run_dir The path to the main run directory.
6318 @param[in] run_id The unique identifier for the run.
6319 @param[in] cfg The dictionary containing the configuration data.
6320 @param[in] section The top-level key in the cfg dictionary.
6321 @param[in] key The second-level key whose value is the list of strings.
6322 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
6323 @param[in] header_sources A dictionary of source files for the header.
6324 @return The absolute path to the generated file.
6326 print(f
"[INFO] Generating {filename}...")
6327 config_dir = os.path.join(run_dir,
"config")
6328 file_path = os.path.join(config_dir, filename)
6331 items = cfg.get(section, {}).get(key, [])
6334 with open(file_path,
"w")
as f: f.write(
"\n".join(lines))
6335 print(f
"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
6336 return os.path.abspath(file_path)
6341 @brief Return True when logging.enabled_functions contains at least one entry.
6342 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
6343 @return Value returned by `has_explicit_monitor_whitelist()`.
6345 items = monitor_cfg.get(
"logging", {}).get(
"enabled_functions", [])
6351 @brief Resolve profiling reporting config from monitor.yml.
6352 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
6353 @return Value returned by `resolve_profiling_config()`.
6355 profiling_cfg = monitor_cfg.get(
"profiling", {})
or {}
6356 timestep_cfg = profiling_cfg.get(
"timestep_output")
6357 final_cfg = profiling_cfg.get(
"final_summary")
6359 if timestep_cfg
is None:
6362 timestep_file =
"Profiling_Timestep_Summary.csv"
6364 if not isinstance(timestep_cfg, dict):
6365 raise ValueError(
"monitor.profiling.timestep_output must be a mapping when provided.")
6366 mode = str(timestep_cfg.get(
"mode",
"off")).lower()
6367 functions = timestep_cfg.get(
"functions", [])
6368 timestep_file = str(timestep_cfg.get(
"file",
"Profiling_Timestep_Summary.csv"))
6370 if mode
not in {
"off",
"selected",
"all"}:
6371 raise ValueError(
"monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
6372 if functions
is None:
6374 if not isinstance(functions, list):
6375 raise ValueError(
"monitor.profiling.timestep_output.functions must be a list of function names.")
6376 if not all(isinstance(item, str)
and item.strip()
for item
in functions):
6377 raise ValueError(
"monitor.profiling.timestep_output.functions entries must be non-empty strings.")
6378 if mode ==
"selected" and not functions:
6379 raise ValueError(
"monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
6380 if mode !=
"selected" and functions:
6381 raise ValueError(
"monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
6382 if not timestep_file:
6383 raise ValueError(
"monitor.profiling.timestep_output.file must be a non-empty string.")
6385 if final_cfg
is None:
6386 final_enabled =
True
6387 elif isinstance(final_cfg, dict):
6388 final_enabled = bool(final_cfg.get(
"enabled",
True))
6390 raise ValueError(
"monitor.profiling.final_summary must be a mapping when provided.")
6394 "functions": functions,
6395 "timestep_file": timestep_file,
6396 "final_summary_enabled": final_enabled,
6400DIAGNOSTICS_PETSC_KEYS = {
6405 "malloc_view_threshold",
6418 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
6419 @param[in] value Candidate value.
6420 @param[in] key Diagnostics key used in error messages.
6421 @return Normalized value.
6423 if isinstance(value, bool)
or value
is None:
6425 if isinstance(value, str)
and value.strip():
6426 return value.strip()
6427 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
6432 @brief Validate a diagnostics boolean value.
6433 @param[in] value Candidate value.
6434 @param[in] key Diagnostics key used in error messages.
6435 @return Boolean value.
6437 if isinstance(value, bool):
6439 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean.")
6444 @brief Validate a diagnostics value that can be false, true, or "all".
6445 @param[in] value Candidate value.
6446 @param[in] key Diagnostics key used in error messages.
6447 @return Normalized value.
6449 if isinstance(value, bool)
or value
is None:
6451 if isinstance(value, str)
and value.strip().lower() ==
"all":
6453 raise ValueError(f
"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
6458 @brief Return an absolute run-local diagnostics file path.
6459 @param[in] run_dir Run directory.
6460 @param[in] filename Diagnostics filename.
6461 @return Absolute diagnostics path under the run logs directory.
6463 return os.path.abspath(os.path.join(run_dir,
"logs", filename))
6468 @brief Resolve true/string diagnostics values to a concrete file path.
6469 @param[in] value Boolean/string diagnostics value.
6470 @param[in] run_dir Run directory.
6471 @param[in] default_filename Default file name when value is true.
6472 @return False, or an absolute/explicit path string.
6476 if isinstance(value, str):
6477 if os.path.isabs(value)
or value.startswith(
":"):
6479 return os.path.abspath(os.path.join(run_dir,
"logs", value))
6485 @brief Resolve monitor diagnostics config and default run-local log paths.
6486 @param[in] monitor_cfg Parsed monitor.yml mapping.
6487 @param[in] run_dir Optional run directory for default artifact paths.
6488 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
6489 @return Normalized diagnostics config.
6491 diagnostics_cfg = (monitor_cfg.get(
"diagnostics", {})
or {})
if isinstance(monitor_cfg, dict)
else {}
6492 if not isinstance(diagnostics_cfg, dict):
6493 raise ValueError(
"monitor.diagnostics must be a mapping when provided.")
6495 petsc_raw = diagnostics_cfg.get(
"petsc", {})
or {}
6496 if not isinstance(petsc_raw, dict):
6497 raise ValueError(
"monitor.diagnostics.petsc must be a mapping when provided.")
6498 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
6500 raise ValueError(f
"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
6503 "malloc_debug":
_diagnostic_bool(petsc_raw.get(
"malloc_debug",
False),
"malloc_debug"),
6504 "malloc_test":
_diagnostic_bool(petsc_raw.get(
"malloc_test",
False),
"malloc_test"),
6505 "malloc_dump":
_diagnostic_bool(petsc_raw.get(
"malloc_dump",
False),
"malloc_dump"),
6507 "malloc_view_threshold": petsc_raw.get(
"malloc_view_threshold"),
6508 "memory_view":
_diagnostic_bool(petsc_raw.get(
"memory_view",
False),
"memory_view"),
6510 "log_view_memory":
_diagnostic_bool(petsc_raw.get(
"log_view_memory",
False),
"log_view_memory"),
6514 "options_left": petsc_raw.get(
"options_left"),
6516 if petsc[
"malloc_view_threshold"]
is not None and not isinstance(petsc[
"malloc_view_threshold"], (int, float)):
6517 raise ValueError(
"monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
6518 if petsc[
"options_left"]
is not None and not isinstance(petsc[
"options_left"], bool):
6519 raise ValueError(
"monitor.diagnostics.petsc.options_left must be boolean or null.")
6521 memory_raw = diagnostics_cfg.get(
"runtime_memory_log", {})
or {}
6522 if not isinstance(memory_raw, dict):
6523 raise ValueError(
"monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
6524 memory_unknown = sorted(set(memory_raw.keys()) - {
"enabled",
"file"})
6526 raise ValueError(f
"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
6527 memory_enabled = memory_raw.get(
"enabled",
True)
6528 if not isinstance(memory_enabled, bool):
6529 raise ValueError(
"monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
6530 memory_file = str(memory_raw.get(
"file",
"Runtime_Memory.log")).strip()
6532 raise ValueError(
"monitor.diagnostics.runtime_memory_log.file must be a non-empty string.")
6534 resolved_petsc = dict(petsc)
6537 suffix =
"PostProcessor" if stage_label ==
"PostProcessor" else "Solver"
6539 "malloc_view": f
"PETSc_MallocView_{suffix}.log",
6540 "log_view": f
"PETSc_LogView_{suffix}.log",
6541 "log_trace": f
"PETSc_LogTrace_{suffix}.log",
6543 for key, default_name
in defaults.items():
6545 if key ==
"log_view" and resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
6546 resolved_value = f
":{resolved_value}"
6547 resolved_petsc[key] = resolved_value
6548 if resolved_value
and isinstance(resolved_value, str)
and not resolved_value.startswith(
":"):
6549 artifacts.append(resolved_value)
6550 elif resolved_value
and isinstance(resolved_value, str)
and resolved_value.startswith(
":"):
6551 artifacts.append(resolved_value[1:])
6553 artifacts.append(os.path.abspath(os.path.join(run_dir,
"logs", memory_file)))
6556 "petsc": resolved_petsc,
6557 "runtime_memory_log": {
"enabled": memory_enabled,
"file": memory_file},
6558 "artifacts": artifacts,
6564 @brief Build PETSc diagnostics command-line arguments for a run stage.
6565 @param[in] monitor_cfg Parsed monitor.yml mapping.
6566 @param[in] run_dir Run directory used to resolve default diagnostics files.
6567 @param[in] stage_label Stage label for default output names.
6568 @return List of executable arguments.
6571 petsc = diagnostics[
"petsc"]
6573 if petsc[
"malloc_debug"]:
6574 args.append(
"-malloc_debug")
6575 if petsc[
"malloc_test"]:
6576 args.append(
"-malloc_test")
6578 (
"malloc_dump",
"-malloc_dump"),
6579 (
"malloc_view",
"-malloc_view"),
6580 (
"memory_view",
"-memory_view"),
6581 (
"log_view",
"-log_view"),
6582 (
"log_trace",
"-log_trace"),
6583 (
"objects_dump",
"-objects_dump"),
6585 value = petsc.get(key)
6589 args.extend([flag, str(value)])
6590 if petsc[
"malloc_view_threshold"]
is not None:
6591 args.extend([
"-malloc_view_threshold", str(petsc[
"malloc_view_threshold"])])
6592 if petsc[
"log_view_memory"]:
6593 args.append(
"-log_view_memory")
6594 if petsc[
"log_all"]:
6595 args.append(
"-log_all")
6596 if petsc[
"options_left"]
is not None:
6597 args.extend([
"-options_left",
"true" if petsc[
"options_left"]
else "false"])
6603 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
6604 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
6605 @param[in] run_id Argument passed to `prepare_monitor_files()`.
6606 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
6607 @param[in] source_files Argument passed to `prepare_monitor_files()`.
6608 @return Value returned by `prepare_monitor_files()`.
6610 print(
"[INFO] Generating monitoring files...")
6612 whitelist_path =
None
6615 run_dir, run_id, monitor_cfg,
"logging",
"enabled_functions",
"whitelist.run", source_files
6618 print(
"[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
6623 if profiling_cfg[
"mode"] ==
"selected":
6627 {
"profiling": {
"selected_functions": profiling_cfg[
"functions"]}},
6629 "selected_functions",
6634 print(f
"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
6636 return {
"whitelist": whitelist_path,
"profile": profile_path,
"profiling": profiling_cfg}
6640 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
6641 and returns a list of their absolute paths.
6642 @details Handles both simple list format (for single-block cases) and a
6643 list-of-lists (for multi-block cases) for boundary conditions.
6644 @param[in] run_dir The path to the main run directory.
6645 @param[in] run_id The unique identifier for the run.
6646 @param[in] case_cfg The parsed case.yml configuration dictionary.
6647 @param[in] source_files A dictionary of source files for the header.
6648 @return A list of absolute paths to the generated BC files.
6649 @throws ValueError if the number of BC definitions does not match the number of blocks.
6651 print(
"[INFO] Generating boundary condition files...")
6652 config_dir = os.path.join(run_dir,
"config")
6653 num_blocks = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
6655 case_path = source_files.get(
"Case")
if source_files
else None
6656 profile_grid_dims =
None
6657 scales = case_cfg.get(
'properties', {}).get(
'scaling', {})
6658 U_ref =
_to_float(scales.get(
'velocity_ref'),
"properties.scaling.velocity_ref")
6660 raise ValueError(
"properties.scaling.velocity_ref must be non-zero for prescribed_flow profile staging.")
6662 if any(bc.get(
"handler") ==
"prescribed_flow" for block
in prepared_blocks
for bc
in block):
6665 generated_files = []
6666 generated_profile_summaries = []
6667 generated_target_grid =
None
6668 field_slice_target_grid =
None
6669 for i, block_bcs_list
in enumerate(prepared_blocks):
6670 file_name =
"bcs.run" if num_blocks == 1
else f
"bcs_block{i}.run"
6671 bcs_file_path = os.path.join(config_dir, file_name)
6674 for bc
in block_bcs_list:
6675 face, bc_type, handler = bc[
'face'], bc[
'type'], bc[
'handler']
6676 params = dict(bc.get(
'params')
or {})
6677 if handler ==
"prescribed_flow":
6678 source = params.pop(
"source")
6680 staged_name = f
"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
6681 staged_path = os.path.join(config_dir, staged_name)
6682 if source[
"type"] ==
"file":
6683 source_path = source[
"path"]
6684 if case_path
and not os.path.isabs(source_path):
6685 source_path = os.path.abspath(os.path.join(os.path.dirname(case_path), source_path))
6686 elif source[
"type"] ==
"generated":
6687 default_output = os.path.join(
6689 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.picslice",
6693 source.get(
"output_file"),
6695 default_to_config_dir=
True,
6697 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6699 f
"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
6701 if source[
"generator"] ==
"square_duct_poiseuille":
6702 if generated_target_grid
is None:
6708 target_grid=generated_target_grid,
6711 script=source.get(
"script"),
6712 case_path=case_path,
6715 raise ValueError(f
"Unsupported generated profile generator '{source['generator']}'.")
6716 summary.update({
"block": i,
"face": face})
6717 generated_profile_summaries.append(summary)
6718 elif source[
"type"] ==
"field_slice":
6719 default_output = os.path.join(
6721 f
"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.picslice",
6725 source.get(
"output_file"),
6727 default_to_config_dir=
True,
6729 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6731 f
"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
6733 if field_slice_target_grid
is None:
6739 field_slice_target_grid,
6744 summary.update({
"block": i,
"face": face})
6745 generated_profile_summaries.append(summary)
6747 raise ValueError(f
"Unsupported prescribed_flow source type '{source.get('type')}'.")
6750 f
"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
6751 f
"{os.path.relpath(staged_path)} dims={summary['dims']}"
6753 params[
"source_file"] = os.path.abspath(staged_path)
6757 for k, v
in params.items():
6758 if isinstance(v, bool):
6759 value_str =
"true" if v
else "false"
6762 parts.append(f
"{k}={value_str}")
6763 params_str =
" ".join(parts)
6764 bcs_lines.append(f
"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
6766 with open(bcs_file_path,
"w")
as f: f.write(
"\n".join(bcs_lines))
6768 print(f
"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
6769 generated_files.append(os.path.abspath(bcs_file_path))
6771 if generated_profile_summaries:
6773 print(f
"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
6775 return generated_files
6779 @brief Converts Python types to C-style command-line flag values.
6780 @param[in] value The Python object to convert (bool, list, or other).
6781 @return A string representation suitable for a C command-line parser.
6783 if isinstance(value, bool):
6784 return "1" if value
else "0"
6785 if isinstance(value, list):
6786 return ",".join(map(str, value))
6791 @brief Return programmatic-grid settings translated to the C node-count contract.
6792 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
6793 @return Value returned by `translate_programmatic_grid_settings()`.
6795 translated = dict(grid_settings)
6796 for dim_key
in (
"im",
"jm",
"km"):
6797 if dim_key
in translated:
6798 raw_val = translated[dim_key]
6799 if not isinstance(raw_val, int)
or raw_val <= 0:
6801 f
"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
6802 f
"(got {raw_val!r})."
6804 translated[dim_key] = raw_val + 1
6810 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
6811 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
6812 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
6813 @param[in] raw_settings programmatic_settings dict from case.yml.
6814 @param[in] dest_path Destination PICGRID file path.
6815 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
6816 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
6819 raise ValueError(
"length_ref must be non-zero for programmatic grid generation.")
6820 IM = int(raw_settings.get(
"im", 0)) + 1
6821 JM = int(raw_settings.get(
"jm", 0)) + 1
6822 KM = int(raw_settings.get(
"km", 0)) + 1
6823 if IM < 2
or JM < 2
or KM < 2:
6825 f
"programmatic_settings im/jm/km must each be >= 1 "
6826 f
"(got im={IM-1}, jm={JM-1}, km={KM-1})."
6828 x_min = float(raw_settings.get(
"xMins", 0.0))
6829 x_max = float(raw_settings.get(
"xMaxs", 1.0))
6830 y_min = float(raw_settings.get(
"yMins", 0.0))
6831 y_max = float(raw_settings.get(
"yMaxs", 1.0))
6832 z_min = float(raw_settings.get(
"zMins", 0.0))
6833 z_max = float(raw_settings.get(
"zMaxs", 1.0))
6834 rx = float(raw_settings.get(
"rxs", 1.0))
6835 ry = float(raw_settings.get(
"rys", 1.0))
6836 rz = float(raw_settings.get(
"rzs", 1.0))
6838 def _stretched(idx, N, length, r):
6840 @brief Mirror of ComputeStretchedCoord from src/grid.c.
6841 @param[in] idx Node index along the axis.
6842 @param[in] N Total node count along the axis.
6843 @param[in] length Physical length of the axis.
6844 @param[in] r Geometric stretching ratio.
6845 @return Coordinate offset from the axis minimum.
6847 frac = idx / (N - 1.0)
6848 if abs(r - 1.0) < 1.0e-9:
6849 return length * frac
6850 return length * (r ** frac - 1.0) / (r - 1.0)
6852 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
6853 os.makedirs(os.path.dirname(dest_path), exist_ok=
True)
6854 with open(dest_path,
"w")
as fout:
6855 fout.write(
"PICGRID\n1\n")
6856 fout.write(f
"{IM} {JM} {KM}\n")
6858 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
6860 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
6862 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
6863 fout.write(f
"{x:.8e} {y:.8e} {z:.8e}\n")
6864 total_nodes = IM * JM * KM
6865 return {
"nblk": 1,
"dims": [(IM, JM, KM)],
"total_nodes": total_nodes}
6868GRID_DA_PROCESSOR_KEYS = (
"da_processors_x",
"da_processors_y",
"da_processors_z")
6873 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
6874 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
6875 @return Value returned by `resolve_grid_da_processor_layout()`.
6880 for key
in GRID_DA_PROCESSOR_KEYS:
6881 value = grid_cfg.get(key)
6882 if isinstance(value, (list, tuple)):
6884 f
"grid.{key} must be a scalar integer. "
6885 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
6887 if value
is not None:
6888 if not isinstance(value, int)
or value <= 0:
6889 raise ValueError(f
"grid.{key} must be a positive integer when provided (got {value}).")
6890 top_level[key] = value
6892 legacy_settings = grid_cfg.get(
"programmatic_settings")
6893 if isinstance(legacy_settings, dict):
6894 for key
in GRID_DA_PROCESSOR_KEYS:
6895 value = legacy_settings.get(key)
6896 if isinstance(value, (list, tuple)):
6898 f
"grid.programmatic_settings.{key} must be a scalar integer. "
6899 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
6901 if value
is not None:
6902 if not isinstance(value, int)
or value <= 0:
6904 f
"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
6909 for key
in GRID_DA_PROCESSOR_KEYS:
6910 top_value = top_level.get(key)
6911 legacy_value = legacy.get(key)
6912 if top_value
is not None and legacy_value
is not None and top_value != legacy_value:
6914 f
"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
6915 "define the processor layout in only one place."
6917 if top_value
is not None:
6918 resolved[key] = top_value
6919 elif legacy_value
is not None:
6920 resolved[key] = legacy_value
6927 @brief Append optional global DMDA layout flags for any grid mode.
6928 @param[in] control_lines Argument passed to `append_grid_da_processor_layout()`.
6929 @param[in] grid_cfg Argument passed to `append_grid_da_processor_layout()`.
6930 @param[in] num_procs Argument passed to `append_grid_da_processor_layout()`.
6935 print(
"[INFO] Letting PETSc automatically determine processor layout.")
6939 print(
"[INFO] Serial run, ignoring da_processors layout.")
6942 if all(layout.get(key)
is not None for key
in GRID_DA_PROCESSOR_KEYS):
6944 for key
in GRID_DA_PROCESSOR_KEYS:
6945 total_layout *= layout[key]
6946 if total_layout != num_procs:
6947 raise ValueError(f
"Processor layout mismatch: product ({total_layout}) != processes ({num_procs}).")
6948 print(f
"[INFO] Applying user-defined processor layout for {num_procs} processes.")
6950 printable =
" x ".join(str(layout.get(key,
"PETSC_DECIDE"))
for key
in GRID_DA_PROCESSOR_KEYS)
6951 print(f
"[INFO] Applying partial processor layout: {printable}.")
6953 for key
in GRID_DA_PROCESSOR_KEYS:
6954 value = layout.get(key)
6955 if value
is not None:
6956 control_lines.append(f
"-{key} {value}")
6960 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
6961 @param[in] value Canonical momentum solver string from YAML.
6962 @return Canonical value accepted by -mom_solver_type.
6963 @throws ValueError if the input cannot be mapped.
6967 raise ValueError(
"momentum solver type cannot be None")
6969 raw = str(value).strip()
6971 "Explicit RK4":
"EXPLICIT_RK",
6972 "Dual Time Picard Jameson RK":
"DUALTIME_PICARD_JAMESON_RK",
6973 "Dual Time Picard RK4":
"DUALTIME_PICARD_JAMESON_RK",
6977 f
"Unknown momentum solver '{value}'. Use one of: "
6978 "'Explicit RK4', 'Dual Time Picard Jameson RK'."
6984 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
6985 @param[in] value Human-readable solution-convergence mode selector.
6986 @return Canonical string accepted by `-solution_convergence_mode`.
6987 @throws ValueError if the input cannot be mapped.
6990 raise ValueError(
"solution_convergence.mode cannot be None")
6992 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
6994 "steady_deterministic":
"STEADY_DETERMINISTIC",
6995 "periodic_deterministic":
"PERIODIC_DETERMINISTIC",
6996 "statistical_steady":
"STATISTICAL_STEADY",
6997 "transient":
"TRANSIENT",
6999 mapped = aliases.get(normalized)
7002 f
"Unknown solution_convergence.mode '{value}'. Use one of: "
7003 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
7009 @brief Maps canonical field init mode names to C enum/int codes (-finit).
7010 @param[in] value Canonical field initialization mode.
7011 @return Canonical integer code accepted by -finit.
7012 @throws ValueError if the input cannot be mapped.
7016 raise ValueError(
"field initialization mode cannot be None")
7022 }.get(str(value).strip())
7025 f
"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
7031 @brief Normalize a file IC field selector to its staged basename and C enum value.
7032 @param[in] value User-facing Ucat or Ucont selector.
7033 @return Tuple of staged field basename and C enum value.
7035 normalized = str(value
or "").strip().lower()
7036 if normalized ==
"ucat":
7038 if normalized ==
"ucont":
7040 raise ValueError(
"initial_conditions.field must be 'Ucat' or 'Ucont'.")
7044 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
7045 @param[in] ic Initial-condition YAML mapping.
7046 @param[in] prepared_blocks Normalized boundary-condition blocks.
7047 @param[in] U_ref Physical reference velocity.
7048 @return Normalized launcher initial-condition contract.
7050 if not isinstance(ic, dict):
7051 raise ValueError(
"properties.initial_conditions must be a mapping.")
7052 mode = str(ic.get(
"mode",
"")).strip()
7055 if mode
in {
"Zero",
"Constant",
"Poiseuille"}:
7058 if finit_code == 1
and params.pop(
"ic_coordinate_system", 0) == 1:
7060 return {
"finit": finit_code,
"cli_params": params,
"kind":
"builtin",
"label": mode}
7062 normalized_mode = mode.lower().replace(
"-",
"_").replace(
" ",
"_")
7063 if normalized_mode ==
"file":
7064 if prepared_blocks
and len(prepared_blocks) > 1:
7065 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
7066 source_file = ic.get(
"source_file")
7067 if not isinstance(source_file, str)
or not source_file.strip():
7068 raise ValueError(
"initial_conditions.source_file is required when mode is 'file'.")
7071 "finit": 4,
"cli_params": {},
"kind":
"file",
"label":
"file",
7072 "source_file": source_file.strip(),
"field_name": field_name,
"field_code": field_code,
7074 if normalized_mode !=
"generated":
7075 raise ValueError(
"initial_conditions.mode must be 'generated' or 'file'.")
7077 generator = str(ic.get(
"generator",
"")).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7078 params = ic.get(
"params", {})
7079 if not isinstance(params, dict):
7080 raise ValueError(
"initial_conditions.params must be a mapping.")
7081 if generator ==
"ic_gen":
7082 if prepared_blocks
and len(prepared_blocks) > 1:
7083 raise ValueError(
"File-backed initial conditions currently support single-block cases only.")
7084 script = params.get(
"script")
7085 if script
is not None and (
not isinstance(script, str)
or not script.strip()):
7086 raise ValueError(
"initial_conditions.params.script must be a non-empty path when provided.")
7088 config_file = params.get(
"config_file")
7089 if not isinstance(config_file, str)
or not config_file.strip():
7090 raise ValueError(
"initial_conditions.params.config_file is required for generator 'ic_gen'.")
7091 cli_args = params.get(
"cli_args", [])
7092 if cli_args
is None:
7094 if not isinstance(cli_args, list):
7095 raise ValueError(
"initial_conditions.params.cli_args must be a list.")
7097 "finit": 4,
"cli_params": {},
"kind":
"ic_gen",
"label":
"ic_gen",
7098 "field_name": field_name,
"field_code": field_code,
7099 "config_file": config_file.strip(),
7100 "script": script.strip()
if script
is not None else None,
7101 "output_file": params.get(
"output_file"),
7102 "cli_args": cli_args,
7106 "zero": (0,
"Zero"),
7107 "constant": (1,
"Constant"),
7108 "streamwise_constant": (3,
"Constant"),
7109 "poiseuille": (2,
"Poiseuille"),
7111 if generator
not in generator_modes:
7113 "initial_conditions.generator must be one of: zero, constant, "
7114 "streamwise_constant, poiseuille, ic_gen."
7116 finit_code, legacy_mode = generator_modes[generator]
7117 legacy_ic = dict(params)
7118 legacy_ic[
"mode"] = legacy_mode
7121 1
if finit_code == 3
else finit_code,
7125 cli_params.pop(
"ic_coordinate_system",
None)
7126 return {
"finit": finit_code,
"cli_params": cli_params,
"kind":
"builtin",
"label": generator}
7130 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
7131 @param[in] path PETSc binary vector path.
7132 @return Summary containing the absolute path and scalar count.
7135 with open(path,
"rb")
as fin:
7136 header = fin.read(8)
7137 if len(header) != 8:
7138 raise ValueError(f
"PETSc Vec file is too short: {path}")
7139 class_id, scalar_count = struct.unpack(
">ii", header)
7140 if class_id != 1211214
or scalar_count < 0:
7141 raise ValueError(f
"Invalid PETSc Vec header in {path}.")
7142 payload = fin.read()
7143 if len(payload) != scalar_count * 8:
7145 f
"PETSc Vec payload size mismatch in {path}: expected {scalar_count * 8} bytes, found {len(payload)}."
7147 return {
"path": os.path.abspath(path),
"scalar_count": scalar_count}
7151 @brief Run the repository IC generator.
7152 @param[in] case_path Source case YAML path.
7153 @param[in] run_dir Run or precompute output directory.
7154 @param[in] resolved_ic Normalized external-generator contract.
7155 @return Generated PETSc vector path.
7157 case_dir = os.path.dirname(os.path.abspath(case_path))
7159 config_file = resolved_ic[
"config_file"]
7160 config_file = config_file
if os.path.isabs(config_file)
else os.path.abspath(os.path.join(case_dir, config_file))
7161 if not os.path.isfile(script):
7162 raise ValueError(f
"ic.gen script not found: {script}")
7163 if not os.path.isfile(config_file):
7164 raise ValueError(f
"initial-condition generator config file not found: {config_file}")
7165 default_output = os.path.join(
"config",
"initial_condition.generated.dat")
7167 run_dir, resolved_ic.get(
"output_file"), default_output, default_to_config_dir=
True
7169 os.makedirs(os.path.dirname(output_path), exist_ok=
True)
7170 cmd = [sys.executable, script,
"-c", config_file,
"--field",
7171 "Ucat" if resolved_ic[
"field_code"] == 0
else "Ucont",
7172 "--output", output_path]
7173 staged_grid = os.path.join(run_dir,
"config",
"grid.run")
7174 if os.path.isfile(staged_grid):
7175 cmd.extend([
"--grid", staged_grid])
7176 cmd.extend(str(token)
for token
in resolved_ic.get(
"cli_args", []))
7177 result = subprocess.run(cmd, cwd=case_dir, text=
True, capture_output=
True)
7178 if result.returncode != 0:
7179 details = (result.stderr
or result.stdout
or "").strip()
7180 raise ValueError(f
"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
7186 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
7187 @param[in] run_dir Run or precompute output directory.
7188 @param[in] case_path Source case YAML path.
7189 @param[in] resolved_ic Normalized file-backed IC contract.
7190 @return Source, staged path, and staging-directory summary.
7192 if resolved_ic[
"kind"] ==
"ic_gen":
7195 source_path = resolved_ic[
"source_file"]
7196 if not os.path.isabs(source_path):
7197 source_path = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(case_path)), source_path))
7198 if not os.path.isfile(source_path):
7199 raise ValueError(f
"Initial-condition source file not found: {source_path}")
7201 stage_dir = os.path.join(run_dir,
"config",
"initial_condition")
7202 os.makedirs(stage_dir, exist_ok=
True)
7203 staged_path = os.path.join(stage_dir, f
"{resolved_ic['field_name']}00000_0.dat")
7204 if os.path.abspath(source_path) != os.path.abspath(staged_path):
7205 shutil.copy2(source_path, staged_path)
7206 return {
"source": os.path.abspath(source_path),
"staged": os.path.abspath(staged_path),
"directory": os.path.abspath(stage_dir)}
7210 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
7211 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
7212 @return Integer 0-5 matching the FlowDirection enum.
7213 @throws ValueError on unknown value.
7217 "+Eta": 2,
"-Eta": 3,
7218 "+Zeta": 4,
"-Zeta": 5,
7219 }.get(str(value).strip())
7222 f
"Unknown initial_conditions.flow_direction '{value}'. "
7223 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
7229 @brief Return True if any prepared BC block contains an INLET face.
7230 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
7231 @return True if at least one INLET entry exists across all blocks.
7233 if not prepared_blocks:
7235 for block_bcs
in prepared_blocks:
7236 for entry
in block_bcs:
7237 if entry.get(
"type") ==
"INLET":
7243 @brief Resolve all IC parameters and return a dict of PETSc option values.
7244 @param[in] ic The properties.initial_conditions mapping.
7245 @param[in] finit_code Normalized -finit integer code.
7246 @param[in] prepared_blocks Normalized BC blocks (may be None).
7247 @param[in] U_ref Reference velocity for non-dimensionalization.
7248 @return Dict with keys matching PETSc option names (without leading dash).
7249 @throws KeyError if a required key is absent.
7250 @throws ValueError on invalid combinations or values.
7258 has_cartesian = any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical"))
7259 has_curvilinear =
"velocity_physical" in ic
7261 if has_cartesian
and has_curvilinear:
7263 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
7264 "velocity_physical (curvilinear) — use one or the other."
7269 result[
"ic_coordinate_system"] = cs_code
7271 vel_phys = float(ic[
"velocity_physical"])
7272 except (TypeError, ValueError)
as exc:
7274 f
"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
7275 "Expected a numeric value."
7277 result[
"ic_velocity_physical"] = vel_phys / U_ref
if U_ref != 0
else 0.0
7279 if "flow_direction" in ic:
7283 "initial_conditions.flow_direction is required for curvilinear Constant IC "
7284 "when no INLET face exists."
7288 if "flow_direction" in ic:
7290 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
7291 "Use velocity_physical + flow_direction for curvilinear mode."
7294 result[
"ic_coordinate_system"] = cs_code
7296 scale = 1.0 / U_ref
if U_ref != 0
else 0.0
7297 result[
"ucont_x"] = u * scale
7298 result[
"ucont_y"] = v * scale
7299 result[
"ucont_z"] = w * scale
7301 elif finit_code == 2:
7302 if any(k
in ic
for k
in (
"u_physical",
"v_physical",
"w_physical")):
7304 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
7306 if "velocity_physical" in ic:
7308 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
7310 if "peak_velocity_physical" not in ic:
7311 raise KeyError(
"peak_velocity_physical")
7313 peak = float(ic[
"peak_velocity_physical"])
7314 except (TypeError, ValueError)
as exc:
7316 f
"Invalid value for initial_conditions.peak_velocity_physical: "
7317 f
"{ic['peak_velocity_physical']!r}. Expected a numeric value."
7319 result[
"ic_velocity_physical"] = peak / U_ref
if U_ref != 0
else 0.0
7321 if "flow_direction" in ic:
7326 fd_axis_name = {0:
"x", 1:
"y", 2:
"z"}.get(fd_int // 2,
"?")
7327 if inlet_axis
and fd_axis_name != inlet_axis:
7328 token = ic[
"flow_direction"]
7330 f
"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
7331 f
"does not match INLET face axis '{inlet_axis}'."
7333 result[
"flow_direction"] = fd_int
7336 "initial_conditions.flow_direction is required for Poiseuille IC "
7337 "when no INLET face exists."
7344 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
7345 @param[in] value Human-readable or enum-like Eulerian field source.
7346 @return Canonical string accepted by `-euler_field_source`.
7347 @throws ValueError if the input cannot be mapped.
7350 raise ValueError(
"eulerian_field_source cannot be None")
7352 normalized = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7356 "analytical":
"analytical",
7358 mapped = aliases.get(normalized)
7361 f
"Unknown operation_mode.eulerian_field_source '{value}'. "
7362 "Use one of: 'solve', 'load', 'analytical'."
7368 @brief Normalizes the analytical solution selector to the C-side canonical string.
7369 @param[in] value Human-readable analytical solution selector.
7370 @return Canonical string accepted by `-analytical_type`.
7371 @throws ValueError if the input cannot be mapped.
7375 raise ValueError(
"analytical_type cannot be None")
7377 normalized = str(value).strip().upper().replace(
"-",
"_").replace(
" ",
"_")
7378 if normalized
not in {
"TGV3D",
"ZERO_FLOW",
"UNIFORM_FLOW"}:
7380 f
"Unknown operation_mode.analytical_type '{value}'. "
7381 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
7387 @brief Parse initial-condition velocity components with mode-aware defaults.
7388 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
7389 @param[in] finit_code Normalized `-finit` integer code.
7390 @param[in] require_explicit If True, all three component keys must be present.
7391 @return Tuple `(u, v, w)` in physical units.
7392 @throws KeyError if a required component key is missing.
7393 @throws ValueError if a component cannot be converted to float.
7395 component_keys = (
"u_physical",
"v_physical",
"w_physical")
7397 for key
in component_keys:
7398 if key
not in initial_conditions:
7399 if require_explicit:
7403 raw_value = initial_conditions[key]
7405 components.append(float(raw_value))
7406 except (TypeError, ValueError)
as exc:
7408 f
"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
7410 return tuple(components)
7414 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
7415 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
7416 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
7417 @throws ValueError if different blocks imply different inlet axes.
7419 face_order = (
"-Xi",
"+Xi",
"-Eta",
"+Eta",
"-Zeta",
"+Zeta")
7421 "-Xi":
"x",
"+Xi":
"x",
7422 "-Eta":
"y",
"+Eta":
"y",
7423 "-Zeta":
"z",
"+Zeta":
"z",
7427 for block_bcs
in prepared_blocks:
7428 face_map = {entry[
"face"]: entry
for entry
in block_bcs}
7429 for face
in face_order:
7430 entry = face_map.get(face)
7431 if entry
and entry[
"type"] ==
"INLET":
7432 inlet_axes.add(face_axis[face])
7437 if len(inlet_axes) != 1:
7439 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
7440 f
"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
7442 return next(iter(inlet_axes))
7446 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
7447 @param[in] value Canonical particle initialization mode.
7448 @return Canonical integer code accepted by -pinit.
7449 @throws ValueError if the input cannot be mapped.
7453 raise ValueError(
"particle init mode cannot be None")
7460 }.get(str(value).strip())
7463 f
"Unknown particle init_mode '{value}'. Use one of: "
7464 "'Surface', 'Volume', 'PointSource', 'SurfaceEdges'."
7470 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
7471 @param[in] value Canonical interpolation method name.
7472 @return Integer code accepted by -interpolation_method.
7473 @throws ValueError if the input cannot be mapped.
7476 raise ValueError(
"interpolation method cannot be None")
7480 "CornerAveraged": 1,
7481 }.get(str(value).strip())
7484 f
"Unknown interpolation_method '{value}'. Use one of: "
7485 "'Trilinear', 'CornerAveraged'."
7491 @brief Maps LES model selectors to C enum/int codes (-les).
7492 @param[in] value LES selector name or legacy integer/bool value.
7493 @return Integer code accepted by -les.
7494 @throws ValueError if the input cannot be mapped.
7496 if isinstance(value, bool):
7497 return 1
if value
else 0
7498 if isinstance(value, int):
7499 if value
in (0, 1, 2):
7501 raise ValueError(
"models.physics.turbulence.les must be 0, 1, 2, false/true, or a supported model block.")
7503 raise ValueError(
"LES model cannot be None")
7505 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7512 "constant_smagorinsky": 1,
7515 "dynamic_smagorinsky": 2,
7519 f
"Unknown LES model '{value}'. Use one of: 'none', "
7520 "'constant_smagorinsky', 'dynamic_smagorinsky'."
7526 @brief Maps LES test-filter names to the C -testfilter_ik flag.
7527 @param[in] value Test-filter selector name or legacy integer/bool value.
7528 @return 0 for volume-weighted box, 1 for homogeneous i/k Simpson filtering.
7529 @throws ValueError if the input cannot be mapped.
7531 if isinstance(value, bool):
7532 return 1
if value
else 0
7533 if isinstance(value, int):
7536 raise ValueError(
"models.physics.turbulence.les.test_filter must be 0, 1, or a supported filter name.")
7538 raise ValueError(
"LES test_filter cannot be None")
7540 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7542 "volume_weighted_box": 0,
7545 "homogeneous_ik": 1,
7546 "ik_homogeneous": 1,
7551 f
"Unknown LES test_filter '{value}'. Use one of: "
7552 "'volume_weighted_box', 'homogeneous_ik'."
7558 @brief Maps RANS model selectors to the current C -rans switch.
7559 @param[in] value RANS selector name or legacy integer/bool value.
7560 @return Integer code accepted by -rans.
7561 @throws ValueError if the input cannot be mapped.
7563 if isinstance(value, bool):
7564 return 1
if value
else 0
7565 if isinstance(value, int):
7568 raise ValueError(
"models.physics.turbulence.rans must be 0, 1, false/true, or a supported model block.")
7570 raise ValueError(
"RANS model cannot be None")
7572 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7581 raise ValueError(f
"Unknown RANS model '{value}'. Use one of: 'none', 'k_omega'.")
7586 @brief Validates wall-function model selectors exposed in YAML.
7587 @param[in] value Wall-function selector name.
7588 @return Canonical wall-function model name.
7589 @throws ValueError if the input cannot be mapped.
7593 key = str(value).strip().lower().replace(
"-",
"_").replace(
" ",
"_")
7594 if key
in {
"log_law",
"loglaw"}:
7596 raise ValueError(
"Unknown wall_function model '%s'. Use: 'log_law'." % value)
7600 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
7601 @param[in] cfg Mapping that may contain `enabled`.
7602 @param[in] path Human-readable config path for diagnostics.
7603 @param[in] default Value used when `enabled` is omitted.
7604 @return Boolean enabled state.
7605 @throws ValueError if `enabled` is not a YAML boolean.
7607 if 'enabled' not in cfg:
7609 if not isinstance(cfg[
'enabled'], bool):
7610 raise ValueError(f
"{path}.enabled must be true or false.")
7611 return cfg[
'enabled']
7615 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
7616 @param[in] models Parsed case.yml `models` mapping.
7617 @param[out] control_lines A list of strings to which C-flags will be appended.
7619 turbulence_cfg = models.get(
'physics', {}).get(
'turbulence', {})
7620 if not turbulence_cfg:
7622 if not isinstance(turbulence_cfg, dict):
7623 raise ValueError(
"models.physics.turbulence must be a mapping.")
7625 les_cfg = turbulence_cfg.get(
'les')
7626 rans_cfg = turbulence_cfg.get(
'rans')
7627 wall_cfg = turbulence_cfg.get(
'wall_function')
7631 if isinstance(les_cfg, dict):
7633 model_value = les_cfg.get(
'model',
'constant_smagorinsky')
7635 control_lines.append(f
"-les {les_code}")
7636 if 'constant_cs' in les_cfg:
7637 control_lines.append(f
"-const_cs {format_flag_value(les_cfg['constant_cs'])}")
7638 if 'max_cs' in les_cfg:
7639 control_lines.append(f
"-max_cs {format_flag_value(les_cfg['max_cs'])}")
7640 if 'dynamic_frequency' in les_cfg:
7641 control_lines.append(f
"-dynamic_freq {format_flag_value(les_cfg['dynamic_frequency'])}")
7642 if 'test_filter' in les_cfg:
7643 control_lines.append(f
"-testfilter_ik {normalize_les_test_filter(les_cfg['test_filter'])}")
7644 elif les_cfg
is not None:
7646 control_lines.append(f
"-les {les_code}")
7648 if isinstance(rans_cfg, dict):
7650 model_value = rans_cfg.get(
'model',
'k_omega')
7652 control_lines.append(f
"-rans {rans_code}")
7653 elif rans_cfg
is not None:
7655 control_lines.append(f
"-rans {rans_code}")
7657 if les_code
and rans_code:
7658 raise ValueError(
"models.physics.turbulence cannot enable both LES and RANS in the same case.")
7660 if isinstance(wall_cfg, dict):
7663 control_lines.append(f
"-wallfunction {1 if enabled else 0}")
7664 if 'roughness_height' in wall_cfg:
7665 control_lines.append(f
"-wall_roughness {format_flag_value(wall_cfg['roughness_height'])}")
7666 elif wall_cfg
is not None:
7667 control_lines.append(f
"-wallfunction {format_flag_value(wall_cfg)}")
7671 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
7672 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
7673 is skipped. All other values are emitted as "<flag> <value>".
7674 @param[out] control_lines The destination list of control-file lines.
7675 @param[in] options Mapping of raw CLI flags to values.
7679 for flag, value
in options.items():
7680 if isinstance(value, bool):
7682 control_lines.append(str(flag))
7684 control_lines.append(f
"{flag} {format_flag_value(value)}")
7687SOLVER_MONITORING_POISSON_FLAG_MAP = {
7688 "pic_true_residual":
"-ps_ksp_pic_monitor_true_residual",
7689 "true_residual":
"-ps_ksp_monitor_true_residual",
7690 "converged_reason":
"-ps_ksp_converged_reason",
7691 "view":
"-ps_ksp_view",
7697 @brief Resolve human-readable solver monitoring YAML to raw control flags.
7698 @param[in] monitor_cfg Parsed monitor.yml mapping.
7699 @return Mapping of raw C/PETSc flags to values.
7701 solver_mon_cfg = monitor_cfg.get(
"solver_monitoring", {})
if isinstance(monitor_cfg, dict)
else {}
7702 if solver_mon_cfg
is None:
7704 if not isinstance(solver_mon_cfg, dict):
7705 raise ValueError(
"monitor.solver_monitoring must be a mapping when provided.")
7709 poisson_cfg = solver_mon_cfg.get(
"poisson", {})
or {}
7710 if not isinstance(poisson_cfg, dict):
7711 raise ValueError(
"monitor.solver_monitoring.poisson must be a mapping when provided.")
7712 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
7714 raise ValueError(f
"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
7715 for key, flag
in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
7716 if key
in poisson_cfg:
7717 value = poisson_cfg[key]
7718 if not isinstance(value, bool):
7719 raise ValueError(f
"monitor.solver_monitoring.poisson.{key} must be boolean.")
7722 passthrough = solver_mon_cfg.get(
"petsc_passthrough_options", {})
or {}
7723 if not isinstance(passthrough, dict):
7724 raise ValueError(
"monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
7725 flags.update(passthrough)
7729 for key, value
in solver_mon_cfg.items()
7730 if isinstance(key, str)
and key.startswith(
"-")
7732 flags.update(legacy_raw)
7734 unknown_top = sorted(
7736 for key
in solver_mon_cfg.keys()
7737 if key
not in {
"poisson",
"petsc_passthrough_options"}
and not (isinstance(key, str)
and key.startswith(
"-"))
7741 "monitor.solver_monitoring has unsupported key(s): "
7742 f
"{unknown_top}. Use 'poisson' for structured monitors or "
7743 "'petsc_passthrough_options' for raw PETSc flags."
7751 @brief Return the effective particle-console snapshot cadence from monitor.yml.
7752 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
7753 @return Value returned by `resolve_particle_console_output_frequency()`.
7755 if 'particle_console_output_frequency' in io_cfg:
7756 return io_cfg[
'particle_console_output_frequency']
7757 return io_cfg.get(
'data_output_frequency')
7761 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
7762 @param[in] case_cfg The parsed case.yml configuration dictionary.
7763 @param[out] control_lines A list of strings to which C-flags will be appended.
7765 models = case_cfg.get(
'models', {})
7767 'domain': {
'blocks':
'-nblk'},
7768 'physics.fsi': {
'immersed':
'-imm',
'moving_fsi':
'-fsi'},
7769 'physics.particles': {
'count':
'-numParticles'},
7770 'statistics': {
'time_averaging':
'-averaging'}
7772 for section_path, flags
in FLAG_MAP.items():
7773 current_level = models
7775 for key
in section_path.split(
'.'): current_level = current_level[key]
7776 for yaml_key, flag
in flags.items():
7777 if yaml_key
in current_level:
7778 control_lines.append(f
"{flag} {format_flag_value(current_level[yaml_key])}")
7779 except KeyError:
continue
7783 if models.get(
'physics', {}).get(
'dimensionality') ==
'2D':
7784 control_lines.append(
"-TwoD 1")
7786 particles_cfg = models.get(
'physics', {}).get(
'particles', {})
7787 p_init_mode_str = particles_cfg.get(
'init_mode',
'Surface')
7789 control_lines.append(f
"-pinit {pinit_code}")
7790 print(f
" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
7793 point_cfg = particles_cfg.get(
'point_source', {})
7794 if not isinstance(point_cfg, dict):
7795 raise ValueError(
"models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
7797 psrc_x = float(point_cfg[
'x'])
7798 psrc_y = float(point_cfg[
'y'])
7799 psrc_z = float(point_cfg[
'z'])
7800 except (KeyError, TypeError, ValueError):
7801 raise ValueError(
"PointSource init_mode requires numeric point_source.{x,y,z} values.")
7802 control_lines.append(f
"-psrc_x {psrc_x}")
7803 control_lines.append(f
"-psrc_y {psrc_y}")
7804 control_lines.append(f
"-psrc_z {psrc_z}")
7805 print(f
" - Particle Point Source: ({psrc_x}, {psrc_y}, {psrc_z})")
7807 p_restart_mode = particles_cfg.get(
'restart_mode')
7809 p_restart_mode_normalized = str(p_restart_mode).lower()
7810 if p_restart_mode_normalized
not in {
"init",
"load"}:
7811 raise ValueError(f
"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
7812 control_lines.append(f
"-particle_restart_mode \"{p_restart_mode}\"")
7816 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
7817 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
7818 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
7821 if 'operation_mode' in solver_cfg
and isinstance(solver_cfg[
'operation_mode'], dict):
7822 op_mode = solver_cfg[
'operation_mode']
7823 if 'eulerian_field_source' in op_mode:
7825 flags[
'-euler_field_source'] = f
"\"{normalized_source}\""
7826 if 'analytical_type' in op_mode
and op_mode.get(
'analytical_type')
is not None:
7828 flags[
'-analytical_type'] = f
"\"{normalized_analytical_type}\""
7829 if normalized_analytical_type ==
"UNIFORM_FLOW":
7830 uniform_flow_cfg = op_mode.get(
'uniform_flow', {})
7831 if not isinstance(uniform_flow_cfg, dict):
7832 raise ValueError(
"operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
7834 flags[
'-analytical_uniform_u'] = float(uniform_flow_cfg[
'u'])
7835 flags[
'-analytical_uniform_v'] = float(uniform_flow_cfg[
'v'])
7836 flags[
'-analytical_uniform_w'] = float(uniform_flow_cfg[
'w'])
7837 except KeyError
as exc:
7838 raise ValueError(f
"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.")
from exc
7839 except (TypeError, ValueError)
as exc:
7840 raise ValueError(
"operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.")
from exc
7842 verification_cfg = solver_cfg.get(
'verification', {})
7843 if verification_cfg:
7844 if not isinstance(verification_cfg, dict):
7845 raise ValueError(
"verification must be a mapping when provided.")
7846 sources_cfg = verification_cfg.get(
'sources', {})
7847 if not isinstance(sources_cfg, dict):
7848 raise ValueError(
"verification.sources must be a mapping when provided.")
7849 diff_cfg = sources_cfg.get(
'diffusivity')
7850 if diff_cfg
is not None:
7851 if not isinstance(diff_cfg, dict):
7852 raise ValueError(
"verification.sources.diffusivity must be a mapping.")
7854 flags[
'-verification_diffusivity_mode'] = f
"\"{str(diff_cfg['mode']).strip().lower()}\""
7855 flags[
'-verification_diffusivity_profile'] = f
"\"{str(diff_cfg['profile']).strip().upper()}\""
7856 flags[
'-verification_diffusivity_gamma0'] = float(diff_cfg[
'gamma0'])
7857 flags[
'-verification_diffusivity_slope_x'] = float(diff_cfg[
'slope_x'])
7858 except KeyError
as exc:
7859 raise ValueError(f
"verification.sources.diffusivity.{exc.args[0]} is required.")
from exc
7860 except (TypeError, ValueError)
as exc:
7861 raise ValueError(
"verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.")
from exc
7863 scalar_cfg = sources_cfg.get(
'scalar')
7864 if scalar_cfg
is not None:
7865 if not isinstance(scalar_cfg, dict):
7866 raise ValueError(
"verification.sources.scalar must be a mapping.")
7868 flags[
'-verification_scalar_mode'] = f
"\"{str(scalar_cfg['mode']).strip().lower()}\""
7869 flags[
'-verification_scalar_profile'] = f
"\"{str(scalar_cfg['profile']).strip().upper()}\""
7870 except KeyError
as exc:
7871 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
7873 scalar_numeric_keys = {
7874 'CONSTANT': (
'value',),
7875 'LINEAR_X': (
'phi0',
'slope_x'),
7876 'SIN_PRODUCT': (
'amplitude',
'kx',
'ky',
'kz'),
7878 profile = str(scalar_cfg.get(
'profile',
'')).strip().upper()
7879 for key
in scalar_numeric_keys.get(profile, ()):
7881 flags[f
'-verification_scalar_{key}'] = float(scalar_cfg[key])
7882 except KeyError
as exc:
7883 raise ValueError(f
"verification.sources.scalar.{exc.args[0]} is required.")
from exc
7884 except (TypeError, ValueError)
as exc:
7885 raise ValueError(f
"verification.sources.scalar.{key} must be numeric.")
from exc
7887 transport_cfg = solver_cfg.get(
'scalar_transport', {})
7889 if not isinstance(transport_cfg, dict):
7890 raise ValueError(
"scalar_transport must be a mapping when provided.")
7892 'schmidt_number':
'-schmidt_number',
7893 'turbulent_schmidt_number':
'-turb_schmidt_number',
7895 unknown_transport_keys = sorted(set(transport_cfg.keys()) - set(transport_map.keys()))
7896 if unknown_transport_keys:
7898 f
"scalar_transport has unsupported key(s): {unknown_transport_keys}. "
7899 "Use 'schmidt_number' or 'turbulent_schmidt_number'."
7901 for key, flag
in transport_map.items():
7902 if key
in transport_cfg:
7904 value = float(transport_cfg[key])
7905 except (TypeError, ValueError)
as exc:
7906 raise ValueError(f
"scalar_transport.{key} must be numeric.")
from exc
7908 raise ValueError(f
"scalar_transport.{key} must be positive.")
7911 selected_solver =
None
7912 if 'strategy' in solver_cfg:
7913 s = solver_cfg[
'strategy']
7914 if 'central_diff' in s:
7917 if 'momentum_solver' in s:
7919 elif 'implicit' in s:
7920 raise ValueError(
"Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
7922 ms = solver_cfg.get(
'momentum_solver', {})
7923 if selected_solver
is None:
7924 selected_solver =
"DUALTIME_PICARD_JAMESON_RK"
7925 flags[
'-mom_solver_type'] = f
"\"{selected_solver}\""
7927 if 'tolerances' in solver_cfg:
7928 t = solver_cfg[
'tolerances']
7930 'max_iterations':
'-mom_max_pseudo_steps',
7931 'absolute_tol':
'-mom_atol',
7932 'relative_tol':
'-mom_rtol',
7933 'residual_absolute_tol':
'-mom_resid_atol',
7934 'residual_relative_tol':
'-mom_resid_rtol',
7935 'step_tol':
'-imp_stol'
7937 for key, flag
in tol_map.items():
7939 flags[flag] = t[key]
7941 def _append_dualtime_options(cfg: dict):
7943 @brief Append dualtime options.
7944 @param[in] cfg Argument passed to `_append_dualtime_options()`.
7946 if 'max_pseudo_steps' in cfg:
7947 flags[
'-mom_max_pseudo_steps'] = cfg[
'max_pseudo_steps']
7948 if 'absolute_tol' in cfg:
7949 flags[
'-mom_atol'] = cfg[
'absolute_tol']
7950 if 'relative_tol' in cfg:
7951 flags[
'-mom_rtol'] = cfg[
'relative_tol']
7952 if 'step_tol' in cfg:
7953 flags[
'-imp_stol'] = cfg[
'step_tol']
7954 if 'pseudo_cfl' in cfg:
7955 pcfl = cfg[
'pseudo_cfl']
7956 if 'initial' in pcfl:
7957 flags[
'-pseudo_cfl'] = pcfl[
'initial']
7958 if 'minimum' in pcfl:
7959 flags[
'-min_pseudo_cfl'] = pcfl[
'minimum']
7960 if 'maximum' in pcfl:
7961 flags[
'-max_pseudo_cfl'] = pcfl[
'maximum']
7962 if 'growth_factor' in pcfl:
7963 flags[
'-pseudo_cfl_growth_factor'] = pcfl[
'growth_factor']
7964 if 'reduction_factor' in pcfl:
7965 flags[
'-pseudo_cfl_reduction_factor'] = pcfl[
'reduction_factor']
7966 if 'jameson_residual_noise_allowance_factor' in cfg:
7967 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'jameson_residual_noise_allowance_factor']
7968 elif 'rk4_residual_noise_allowance_factor' in cfg:
7969 flags[
'-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg[
'rk4_residual_noise_allowance_factor']
7971 if isinstance(ms, dict):
7972 allowed_ms_keys = {
'type',
'dual_time_picard_jameson_rk',
'dual_time_picard_rk4'}
7973 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
7976 f
"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
7977 "Currently supported block: 'dual_time_picard_jameson_rk'."
7980 if 'dual_time_picard_jameson_rk' in ms
and 'dual_time_picard_rk4' in ms:
7982 "Use only momentum_solver.dual_time_picard_jameson_rk; "
7983 "do not also set its deprecated dual_time_picard_rk4 alias."
7985 dt_picard_cfg = ms.get(
'dual_time_picard_jameson_rk', ms.get(
'dual_time_picard_rk4'))
7986 if dt_picard_cfg
is not None:
7987 if selected_solver !=
"DUALTIME_PICARD_JAMESON_RK":
7989 f
"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
7991 if not isinstance(dt_picard_cfg, dict):
7992 raise ValueError(
"momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
7993 if (
'jameson_residual_noise_allowance_factor' in dt_picard_cfg
and
7994 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
7996 "Use only jameson_residual_noise_allowance_factor; "
7997 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
7999 _append_dualtime_options(dt_picard_cfg)
8000 solution_convergence_cfg = solver_cfg.get(
'solution_convergence', {})
8001 if solution_convergence_cfg
is not None:
8002 if not isinstance(solution_convergence_cfg, dict):
8003 raise ValueError(
"solution_convergence must be a mapping when provided.")
8004 if solution_convergence_cfg
and solution_convergence_cfg.get(
'enabled',
True):
8006 flags[
'-solution_convergence_mode'] = f
"\"{mode}\""
8007 if mode ==
"PERIODIC_DETERMINISTIC":
8008 periodic_cfg = solution_convergence_cfg.get(
'periodic_deterministic')
8009 if not isinstance(periodic_cfg, dict):
8010 raise ValueError(
"solution_convergence.periodic_deterministic must be a mapping when mode is 'periodic_deterministic'.")
8011 flags[
'-solution_convergence_period_steps'] = periodic_cfg[
'period_steps']
8012 if mode ==
"STATISTICAL_STEADY":
8013 statistical_cfg = solution_convergence_cfg.get(
'statistical_steady')
8014 if not isinstance(statistical_cfg, dict):
8015 raise ValueError(
"solution_convergence.statistical_steady must be a mapping when mode is 'statistical_steady'.")
8016 flags[
'-solution_convergence_window_steps'] = statistical_cfg[
'window_steps']
8017 def _normalize_poisson_method(value) -> str:
8019 @brief Normalize a user-facing Poisson linear-solver method name.
8020 @param[in] value Method value from the solver YAML.
8021 @return Lowercase PETSc KSP method token.
8023 method = str(value).strip().lower()
8025 raise ValueError(
"poisson_solver.method cannot be empty.")
8028 def _normalize_poisson_preconditioner(value) -> str:
8030 @brief Normalize and validate the outer Poisson preconditioner name.
8031 @param[in] value Preconditioner value from the solver YAML.
8032 @return PETSc PC token for the supported outer preconditioner.
8034 pc = str(value).strip().lower()
8035 aliases = {
"mg":
"multigrid",
"pcmg":
"multigrid"}
8036 pc = aliases.get(pc, pc)
8037 if pc !=
"multigrid":
8039 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
8040 "The runtime Poisson solver still assumes PETSc PCMG setup."
8044 def _poisson_level_number(level_name) -> str:
8046 @brief Extract the numeric suffix from a `level_N` multigrid level key.
8047 @param[in] level_name YAML level key supplied by the user.
8048 @return Numeric level suffix as a string.
8050 text = str(level_name).strip()
8051 match = re.fullmatch(
r"level_(\d+)", text)
8053 raise ValueError(f
"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
8054 return match.group(1)
8056 def _append_poisson_solver_flags(ps: dict, source_key: str):
8058 @brief Append structured Poisson solver options to the flat PETSc flag map.
8059 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
8060 @param[in] source_key Name of the source YAML block, used in error messages.
8062 if not isinstance(ps, dict):
8063 raise ValueError(f
"{source_key} must be a mapping when provided.")
8067 method = _normalize_poisson_method(ps[
'method'])
8068 flags[
'-ps_ksp_type'] = method
8069 if 'absolute_tolerance' in ps:
8070 flags[
'-ps_ksp_atol'] = ps[
'absolute_tolerance']
8071 flags[
'-poisson_tol'] = ps[
'absolute_tolerance']
8072 if 'relative_tolerance' in ps:
8073 flags[
'-ps_ksp_rtol'] = ps[
'relative_tolerance']
8074 if 'max_iterations' in ps:
8075 flags[
'-ps_ksp_max_it'] = ps[
'max_iterations']
8076 if 'tolerance' in ps:
8077 flags[
'-poisson_tol'] = ps[
'tolerance']
8079 gmres_cfg = ps.get(
'gmres', {})
8080 if gmres_cfg
is not None:
8081 if not isinstance(gmres_cfg, dict):
8082 raise ValueError(f
"{source_key}.gmres must be a mapping when provided.")
8083 if 'restart' in gmres_cfg:
8085 method = _normalize_poisson_method(ps.get(
'method',
'fgmres'))
8086 flags.setdefault(
'-ps_ksp_type', method)
8087 if method
not in {
"gmres",
"fgmres",
"lgmres"}:
8089 f
"{source_key}.gmres.restart is valid only when {source_key}.method "
8090 "is one of 'gmres', 'fgmres', or 'lgmres'."
8092 flags[
'-ps_ksp_gmres_restart'] = gmres_cfg[
'restart']
8094 preconditioner_cfg = ps.get(
'preconditioner', {})
8095 if preconditioner_cfg:
8096 if not isinstance(preconditioner_cfg, dict):
8097 raise ValueError(f
"{source_key}.preconditioner must be a mapping when provided.")
8098 if 'type' in preconditioner_cfg:
8099 flags[
'-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg[
'type'])
8101 if 'multigrid' in ps:
8102 mg = ps[
'multigrid']
8103 if not isinstance(mg, dict):
8104 raise ValueError(f
"{source_key}.multigrid must be a mapping when provided.")
8105 mg_map = {
'levels':
'-mg_level',
'pre_sweeps':
'-mg_pre_it',
'post_sweeps':
'-mg_post_it'}
8106 for key, flag
in mg_map.items():
8107 if key
in mg: flags[flag] = mg[key]
8109 cycle = str(mg[
'cycle']).strip().lower()
8110 if cycle
not in {
"v"}:
8111 raise ValueError(f
"{source_key}.multigrid.cycle currently supports only 'v'.")
8113 mode = str(mg[
'mode']).strip().lower()
8114 if mode
not in {
"multiplicative"}:
8115 raise ValueError(f
"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
8116 if 'semi_coarsening' in mg:
8117 sc = mg[
'semi_coarsening']
8118 if not isinstance(sc, dict):
8119 raise ValueError(f
"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
8123 if 'level_solvers' in mg:
8124 level_solvers = mg[
'level_solvers']
8125 if not isinstance(level_solvers, dict):
8126 raise ValueError(f
"{source_key}.multigrid.level_solvers must be a mapping when provided.")
8127 for level_name, settings
in level_solvers.items():
8128 if not isinstance(settings, dict):
8129 raise ValueError(f
"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
8130 level_num = _poisson_level_number(level_name)
8131 for key, value
in settings.items():
8132 mapped_key = {
'method':
'ksp_type',
'preconditioner':
'pc_type'}.get(key, key)
8135 if 'poisson_solver' in solver_cfg
and 'pressure_solver' in solver_cfg:
8136 if solver_cfg[
'poisson_solver'] != solver_cfg[
'pressure_solver']:
8138 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
8139 "Use 'poisson_solver' only, or make the legacy alias identical."
8141 poisson_cfg = solver_cfg.get(
'poisson_solver', solver_cfg.get(
'pressure_solver'))
8142 if poisson_cfg
is not None:
8143 source_key =
'poisson_solver' if 'poisson_solver' in solver_cfg
else 'pressure_solver'
8144 _append_poisson_solver_flags(poisson_cfg, source_key)
8145 interp_cfg = solver_cfg.get(
'interpolation', {})
8146 if isinstance(interp_cfg, dict):
8147 interp_method_str = interp_cfg.get(
'method',
'Trilinear')
8149 interp_method_str =
'Trilinear'
8151 flags[
'-interpolation_method'] = interp_code
8152 print(f
" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
8154 if 'petsc_passthrough_options' in solver_cfg:
8155 passthrough = solver_cfg[
'petsc_passthrough_options']
8157 for key, value
in passthrough.items():
8160 if '-ps_ksp_type' in flags:
8161 summary_bits.append(f
"method={flags['-ps_ksp_type']}")
8162 if '-ps_ksp_atol' in flags:
8163 summary_bits.append(f
"atol={flags['-ps_ksp_atol']}")
8164 if '-ps_ksp_rtol' in flags:
8165 summary_bits.append(f
"rtol={flags['-ps_ksp_rtol']}")
8166 if '-ps_ksp_max_it' in flags:
8167 summary_bits.append(f
"max_it={flags['-ps_ksp_max_it']}")
8168 if '-mg_level' in flags:
8169 summary_bits.append(f
"mg_levels={flags['-mg_level']}")
8171 print(f
" - Poisson Solver: {', '.join(summary_bits)}")
8176 @brief Generates the main .control file for the C-solver.
8177 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
8178 into a single, machine-readable file of command-line flags.
8179 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
8180 @param[in] run_id Argument passed to `generate_solver_control_file()`.
8181 @param[in] configs Argument passed to `generate_solver_control_file()`.
8182 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
8183 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
8184 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
8185 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
8186 @return Value returned by `generate_solver_control_file()`.
8188 print(
"[INFO] Generating master solver control file...")
8189 case_cfg, solver_cfg, monitor_cfg = configs[
'case'], configs[
'solver'], configs[
'monitor']
8190 source_files = {
'Case': configs[
'case_path'],
'Solver': configs[
'solver_path'],
'Monitor': configs[
'monitor_path']}
8194 props, run_ctrl = case_cfg[
'properties'], case_cfg[
'run_control']
8195 scales, fluid, ic = props[
'scaling'], props[
'fluid'], props[
'initial_conditions']
8197 L_ref, U_ref, rho, mu = float(scales[
'length_ref']), float(scales[
'velocity_ref']), float(fluid[
'density']), float(fluid[
'viscosity'])
8198 reynolds = (rho * U_ref * L_ref) / mu
if mu != 0
else float(
'inf')
8199 dt_phys = float(run_ctrl[
'dt_physical'])
8200 T_ref = L_ref / U_ref
if U_ref != 0
else float(
'inf')
8201 dt_nondim = dt_phys / T_ref
if T_ref != float(
'inf')
else 0.0
8203 finit_mode_str = resolved_ic[
"label"]
8204 finit_code = resolved_ic[
"finit"]
8205 ic_params = resolved_ic[
"cli_params"]
8206 print(f
" - Reynolds Number (Re) = {reynolds:.4f}")
8207 print(f
" - Non-Dimensional dt* = {dt_nondim:.6f}")
8209 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
8211 start_step = int(run_ctrl.get(
"start_step", 0)
or 0)
8212 ic_is_authoritative = eulerian_source ==
"solve" and start_step == 0
8214 if ic_is_authoritative:
8215 print(f
" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
8216 if "ucont_x" in ic_params:
8218 f
"-ucont_x {ic_params['ucont_x']}",
8219 f
"-ucont_y {ic_params['ucont_y']}",
8220 f
"-ucont_z {ic_params['ucont_z']}",
8222 if "ic_velocity_physical" in ic_params:
8223 ic_cli.append(f
"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
8224 if "flow_direction" in ic_params:
8225 ic_cli.append(f
"-flow_direction {ic_params['flow_direction']}")
8228 f
"[WARN] Ignoring configured initial condition '{finit_mode_str}' because "
8229 f
"eulerian_field_source={eulerian_source!r} and start_step={start_step} select another source.",
8233 control_lines.extend([
8234 f
"-start_step {run_ctrl['start_step']}", f
"-totalsteps {run_ctrl['total_steps']}",
8235 f
"-ren {reynolds}", f
"-dt {dt_nondim}", f
"-finit {finit_code}",
8237 f
"-scaling_L_ref {L_ref}", f
"-scaling_U_ref {U_ref}", f
"-scaling_rho_ref {rho}"
8239 except (KeyError, TypeError, ZeroDivisionError, ValueError)
as e:
8240 print(f
"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
8244 if monitor_files.get(
"whitelist"):
8245 control_lines.append(f
"-whitelist_config_file {monitor_files['whitelist']}")
8246 if monitor_files.get(
"profile"):
8247 control_lines.append(f
"-profile_config_file {monitor_files['profile']}")
8248 profiling_cfg = monitor_files.get(
"profiling", {})
8249 control_lines.append(f
"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
8250 if profiling_cfg.get(
"mode") !=
"off":
8251 control_lines.append(f
"-profiling_timestep_file {profiling_cfg.get('timestep_file', 'Profiling_Timestep_Summary.csv')}")
8252 control_lines.append(f
"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
8254 memory_log_cfg = diagnostics_cfg[
"runtime_memory_log"]
8255 control_lines.append(f
"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
8256 control_lines.append(f
"-runtime_memory_log_file {memory_log_cfg.get('file', 'Runtime_Memory.log')}")
8258 walltime_guard_policy = configs.get(
"walltime_guard_policy")
8259 if walltime_guard_policy
is not None:
8260 control_lines.extend(
8262 f
"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
8263 f
"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
8264 f
"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
8265 f
"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
8266 f
"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
8270 grid_cfg = case_cfg.get(
'grid', {})
8271 grid_mode = grid_cfg.get(
'mode')
8272 expected_nblk = int(case_cfg.get(
'models', {}).get(
'domain', {}).get(
'blocks', 1))
8274 if grid_mode ==
'file':
8275 print(
"[INFO] Grid Mode: Using external file...")
8276 case_file_dir = os.path.dirname(configs[
'case_path'])
8277 source_grid = grid_cfg[
'source_file']
8278 if not os.path.isabs(source_grid):
8279 source_grid = os.path.abspath(os.path.join(case_file_dir, source_grid))
8280 grid_for_validation = source_grid
8281 legacy_cfg = grid_cfg.get(
"legacy_conversion")
8282 if isinstance(legacy_cfg, dict):
8283 if legacy_cfg.get(
"enabled",
True):
8284 print(
"[INFO] Grid file legacy conversion enabled; converting with grid.gen...")
8286 configs[
'case_path'],
8291 nondim_grid_path = os.path.join(run_dir,
"config",
"grid.run")
8294 grid_for_validation, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8297 f
"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
8298 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8300 control_lines.append(f
"-grid_file {nondim_grid_path}")
8301 except Exception
as e:
8302 print(f
"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
8304 elif grid_mode ==
'grid_gen':
8305 print(
"[INFO] Grid Mode: Generating external grid via grid.gen...")
8306 nondim_grid_path = os.path.join(run_dir,
"config",
"grid.run")
8307 if continue_mode
and os.path.isfile(nondim_grid_path):
8308 print(f
"[INFO] Continue mode: reusing staged grid: {os.path.relpath(nondim_grid_path)}")
8309 control_lines.append(f
"-grid_file {nondim_grid_path}")
8315 generated_grid, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8318 f
"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
8319 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8321 control_lines.append(f
"-grid_file {nondim_grid_path}")
8322 except Exception
as e:
8323 print(f
"[FATAL] Grid generation failed: {e}", file=sys.stderr)
8325 elif grid_mode ==
'programmatic_c':
8326 print(
"[INFO] Grid Mode: Programmatic C...")
8328 control_lines.append(
"-grid")
8329 for p_key
in GRID_DA_PROCESSOR_KEYS:
8330 grid_settings.pop(p_key,
None)
8331 for key, value
in grid_settings.items(): control_lines.append(f
"-{key} {format_flag_value(value)}")
8332 if resolved_ic[
"kind"] ==
"ic_gen" and ic_is_authoritative:
8333 nondim_grid_path = os.path.join(run_dir,
"config",
"grid.run")
8336 grid_cfg.get(
'programmatic_settings', {}), nondim_grid_path, L_ref
8339 f
"[INFO] Materialized grid.run for ic_gen: {os.path.relpath(nondim_grid_path)} "
8340 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8342 except Exception
as e:
8343 print(f
"[FATAL] Failed to generate grid.run for ic_gen: {e}", file=sys.stderr)
8346 raise ValueError(f
"Unknown or missing grid mode '{grid_mode}' in case.yml.")
8348 if resolved_ic[
"kind"]
in {
"file",
"ic_gen"}:
8349 if ic_is_authoritative:
8352 except Exception
as e:
8353 print(f
"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
8355 control_lines.extend([
8356 f
"-ic_field {resolved_ic['field_code']}",
8357 f
"-ic_dir {staged_ic['directory']}",
8359 print(f
" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
8363 except ValueError
as e:
8364 print(f
"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
8366 control_lines.append(f
"-bcs_files \"{','.join(bcs_files)}\"")
8372 if 'solver_parameters' in case_cfg:
8373 params = case_cfg[
'solver_parameters']
8375 for key, value
in params.items():
8376 control_lines.append(f
"{key} {format_flag_value(value)}")
8380 except ValueError
as e:
8381 print(f
"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
8383 for flag, value
in solver_flags.items(): control_lines.append(f
"{flag} {value}")
8387 except ValueError
as e:
8388 print(f
"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
8392 io_cfg = monitor_cfg.get(
'io', {})
8394 if 'data_output_frequency' in io_cfg: control_lines.append(f
"-tio {io_cfg['data_output_frequency']}")
8395 if particle_console_output_freq
is not None:
8396 control_lines.append(f
"-particle_console_output_freq {particle_console_output_freq}")
8397 if 'particle_log_interval' in io_cfg: control_lines.append(f
"-logfreq {io_cfg['particle_log_interval']}")
8398 if 'directories' in io_cfg:
8399 dirs = io_cfg[
'directories']
8400 if 'output' in dirs: control_lines.append(f
"-output_dir {dirs['output']}")
8401 if 'restart' in dirs
and not restart_source_dir:
8402 control_lines.append(f
"-restart_dir {dirs['restart']}")
8403 if 'log' in dirs: control_lines.append(f
"-log_dir {dirs['log']}")
8404 if 'eulerian_subdir' in dirs: control_lines.append(f
"-euler_subdir {dirs['eulerian_subdir']}")
8405 if 'particle_subdir' in dirs: control_lines.append(f
"-particle_subdir {dirs['particle_subdir']}")
8406 if restart_source_dir:
8407 control_lines.append(f
"-restart_dir {restart_source_dir}")
8409 control_lines.append(
"-continue_mode true")
8411 final_content =
generate_header(run_id, source_files) +
"\n".join(control_lines)
8412 control_file_path = os.path.join(run_dir,
"config", f
"{run_id}.control")
8413 with open(control_file_path,
"w")
as f: f.write(final_content)
8414 print(f
"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
8415 return os.path.abspath(control_file_path)
8419 @brief Generates a key=value config file (post.run) for the C post-processor.
8420 @details Translates the structured post-processing YAML into the specific flat
8421 key-value format required by the C executable, including complex,
8422 semicolon-separated pipeline strings.
8423 @param[in] run_dir The path to the main run directory.
8424 @param[in] run_id The unique identifier for the run.
8425 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
8426 @param[in] source_files A dictionary of source files for the header.
8427 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
8428 @return The absolute path to the generated post.run recipe file.
8430 print(
"[INFO] Generating post-processor recipe file (post.run)...")
8431 config_dir = os.path.join(run_dir,
"config")
8432 post_recipe_path = os.path.join(config_dir,
"post.run")
8437 for key, value
in c_config.items():
8438 if value
is not None and str(value) !=
"":
8439 lines.append(f
"{key} = {value}")
8441 with open(post_recipe_path,
"w")
as f:
8442 f.write(
"\n".join(lines))
8443 print(f
"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
8444 return os.path.abspath(post_recipe_path)
8446def execute_command(command: list, run_dir: str, log_filename: str, monitor_cfg: dict =
None):
8448 @brief Executes a command, streaming its output to the console and a log file.
8450 If None, the process inherits the parent's environment directly.
8451 @param[in] command Argument passed to `execute_command()`.
8452 @param[in] run_dir Argument passed to `execute_command()`.
8453 @param[in] log_filename Argument passed to `execute_command()`.
8454 @param[in] monitor_cfg Argument passed to `execute_command()`.
8457 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
8459 print(f
"[INFO] Launching Command...\n > {format_command_for_display(command)}")
8460 print(f
" Log file: {os.path.relpath(log_path)}")
8465 "stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
8466 "cwd": run_dir,
"bufsize": 1,
"universal_newlines":
True,
8467 "encoding":
'utf-8',
"errors":
'replace'
8471 print(
"[INFO] Creating custom environment to set LOG_LEVEL.")
8472 run_env = os.environ.copy()
8473 verbosity = monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()
8474 run_env[
'LOG_LEVEL'] = verbosity
8475 print(f
"[INFO] Setting LOG_LEVEL={verbosity} for C executable.")
8476 popen_kwargs[
'env'] = run_env
8478 print(
"[INFO] Using inherited environment for process.")
8483 process = subprocess.Popen(command, **popen_kwargs)
8485 with open(log_path,
"w")
as log_file:
8486 for line
in process.stdout:
8487 sys.stdout.write(line)
8488 log_file.write(line)
8490 return_code = process.returncode
8492 if return_code == 0:
8493 print(f
"[SUCCESS] Execution finished successfully.")
8495 print(f
"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
8496 sys.exit(return_code)
8497 except FileNotFoundError:
8498 print(f
"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
8499 print(
" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
8501 except Exception
as e:
8502 print(f
"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
8508 @brief Render a shell-safe command string for console and log output.
8509 @param[in] command Argument passed to `format_command_for_display()`.
8510 @return Value returned by `format_command_for_display()`.
8512 return " ".join(shlex.quote(str(part))
for part
in command)
8517 @brief Resolve a command log filename relative to the run directory.
8518 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
8519 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
8520 @return Value returned by `resolve_command_log_path()`.
8522 if os.path.dirname(log_filename):
8523 return os.path.join(run_dir, log_filename)
8524 return os.path.join(run_dir,
"logs", log_filename)
8529 @brief Raised when an external command exits unsuccessfully.
8532 def __init__(self, command: list, returncode: int, details: str =
None):
8534 @brief Initialize a command execution error.
8535 @param[in] command Argument passed to `__init__()`.
8536 @param[in] returncode Argument passed to `__init__()`.
8537 @param[in] details Argument passed to `__init__()`.
8542 detail_suffix = f
": {details}" if details
else ""
8544 f
"Command failed with exit code {returncode}: {format_command_for_display(command)}{detail_suffix}"
8550 @brief Raised when plot.gen reports a missing optional dependency.
8556 @brief Run a command and capture combined stdout/stderr details for later inspection.
8557 @param[in] command Argument passed to `_run_captured_command()`.
8558 @param[in] run_dir Argument passed to `_run_captured_command()`.
8559 @return Value returned by `_run_captured_command()`.
8562 return subprocess.run(
8566 capture_output=
True,
8571 except FileNotFoundError
as exc:
8572 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
8577 @brief Raise `CommandExecutionError` when a captured command failed.
8578 @param[in] command Argument passed to `_require_successful_command()`.
8579 @param[in] result Argument passed to `_require_successful_command()`.
8581 if result.returncode == 0:
8583 details = (result.stderr
or result.stdout).strip()
8589 @brief Run a command, require success, and return stripped stdout text.
8590 @param[in] command Argument passed to `_capture_command_stdout()`.
8591 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
8592 @return Value returned by `_capture_command_stdout()`.
8596 return result.stdout.strip()
8601 @brief Stream command output to stdout and an already-open log file.
8602 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
8603 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
8604 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
8607 print(f
"[INFO] Running: {display}")
8608 log_file.write(f
"$ {display}\n")
8612 "stdout": subprocess.PIPE,
8613 "stderr": subprocess.STDOUT,
8616 "universal_newlines":
True,
8617 "encoding":
"utf-8",
8618 "errors":
"replace",
8622 process = subprocess.Popen(command, **popen_kwargs)
8623 except FileNotFoundError
as exc:
8624 raise CommandExecutionError(command, 1, f
"Command not found or is not executable: '{command[0]}'")
from exc
8627 for line
in process.stdout:
8628 sys.stdout.write(line)
8629 log_file.write(line)
8630 return_code = process.wait()
8631 log_file.write(
"\n")
8633 if return_code != 0:
8639 @brief Capture the current git HEAD branch name and commit hash.
8640 @param[in] run_dir Argument passed to `_get_git_head_state()`.
8641 @return Value returned by `_get_git_head_state()`.
8644 branch_result =
_run_captured_command([
"git",
"symbolic-ref",
"--quiet",
"--short",
"HEAD"], run_dir)
8645 branch_name = branch_result.stdout.strip()
if branch_result.returncode == 0
else None
8646 return {
"branch": branch_name,
"commit": head_commit}
8651 @brief Return local branch names plus their configured upstreams.
8652 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
8653 @return Value returned by `_get_local_branches_with_upstreams()`.
8656 [
"git",
"for-each-ref",
"--sort=refname",
"--format=%(refname:short)\t%(upstream:short)",
"refs/heads"],
8660 for line
in output.splitlines():
8661 if not line.strip():
8663 branch_name, _, upstream_name = line.partition(
"\t")
8664 branches.append((branch_name, upstream_name
or None))
8670 @brief Return `True` when the repository has staged or unstaged tracked changes.
8671 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
8672 @return Value returned by `_working_tree_has_tracked_changes()`.
8674 command = [
"git",
"status",
"--porcelain",
"--untracked-files=no"]
8677 return bool(result.stdout.strip())
8682 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
8683 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
8684 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
8685 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
8687 cleanup_command = [
"git",
"rebase",
"--abort"]
if rebase
else [
"git",
"merge",
"--abort"]
8689 if result.returncode == 0:
8690 print(f
"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
8691 log_file.write(f
"$ {format_command_for_display(cleanup_command)}\n")
8693 sys.stdout.write(result.stdout)
8694 log_file.write(result.stdout)
8696 sys.stderr.write(result.stderr)
8697 log_file.write(result.stderr)
8698 log_file.write(
"\n")
8702 details = (result.stderr
or result.stdout).strip()
8705 f
"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
8707 print(message, file=sys.stderr)
8708 log_file.write(message +
"\n")
8714 @brief Restore the repository back to the branch or detached commit it started on.
8715 @param[in] run_dir Argument passed to `_restore_git_head()`.
8716 @param[in] original_head Argument passed to `_restore_git_head()`.
8717 @param[in] log_file Argument passed to `_restore_git_head()`.
8720 if original_head[
"branch"]:
8721 if current_state[
"branch"] == original_head[
"branch"]:
8726 if current_state[
"branch"]
is None and current_state[
"commit"] == original_head[
"commit"]:
8733 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
8734 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
8735 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
8736 @param[in] rebase Argument passed to `pull_all_source_branches()`.
8739 os.makedirs(os.path.dirname(log_path), exist_ok=
True)
8741 print(
"\n" +
"="*23 +
" PULL SOURCE STAGE " +
"="*22)
8742 print(
"[INFO] Refreshing all local source branches that track an upstream.")
8743 print(f
" Log file: {os.path.relpath(log_path)}")
8750 "Multi-branch pull requires a clean tracked working tree in the source repository. "
8751 "Commit or stash those changes first, or rerun with --current-branch-only."
8754 except (CommandExecutionError, RuntimeError)
as exc:
8755 print(f
"[FATAL] {exc}", file=sys.stderr)
8756 sys.exit(getattr(exc,
"returncode", 1))
8759 print(
"[FATAL] No local branches were found in the source repository.", file=sys.stderr)
8762 if original_head[
"branch"]:
8763 branches = [item
for item
in branches
if item[0] != original_head[
"branch"]] + [
8764 item
for item
in branches
if item[0] == original_head[
"branch"]
8767 skipped_branches = []
8768 current_operation =
None
8770 restore_error =
None
8772 with open(log_path,
"w", encoding=
"utf-8")
as log_file:
8773 log_file.write(f
"# PICurv pull-source all-branch sync\n")
8774 log_file.write(f
"# repository: {os.path.abspath(run_dir)}\n")
8775 log_file.write(f
"# started: {datetime.now().isoformat()}\n")
8777 f
"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
8781 for branch_name, upstream_name
in branches:
8782 if not upstream_name:
8783 warning = f
"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
8784 print(warning, file=sys.stderr)
8785 log_file.write(warning +
"\n")
8786 skipped_branches.append(branch_name)
8789 print(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
8790 log_file.write(f
"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
8792 current_operation = f
"checkout:{branch_name}"
8795 pull_command = [
"git",
"pull"]
8797 pull_command.append(
"--rebase")
8798 current_operation = f
"pull:{branch_name}"
8800 current_operation =
None
8801 except CommandExecutionError
as exc:
8803 if current_operation
and current_operation.startswith(
"pull:"):
8808 except CommandExecutionError
as exc:
8815 f
"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
8816 f
"Check log: {os.path.relpath(log_path)}",
8819 sys.exit(restore_error.returncode)
8821 f
"[FATAL] Multi-branch pull failed. Original branch restored. "
8822 f
"Check log: {os.path.relpath(log_path)}",
8825 sys.exit(pull_error.returncode)
8829 f
"[FATAL] Branch updates completed, but the original branch could not be restored. "
8830 f
"Check log: {os.path.relpath(log_path)}",
8833 sys.exit(restore_error.returncode)
8835 if skipped_branches:
8836 print(f
"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
8837 print(
"[SUCCESS] All local tracking branches are up to date.")
8841 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
8842 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
8843 @return Value returned by `auto_identify_run_inputs()`.
8845 all_yml_files = glob.glob(os.path.join(config_dir,
"*.yml"))
8846 case_path, monitor_path =
None,
None
8847 for f_path
in all_yml_files:
8850 if not isinstance(content, dict):
8852 if 'models' in content
and 'boundary_conditions' in content:
8854 elif 'io' in content
and 'logging' in content:
8855 monitor_path = f_path
8856 except Exception
as e:
8857 print(f
"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
8859 solver_control_path = glob.glob(os.path.join(config_dir,
"*.control"))[0]
8861 solver_control_path =
None
8862 return case_path, monitor_path, solver_control_path
8866 @brief Resolve post source directory token and optionally enforce existence.
8867 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
8868 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
8869 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
8870 @param[in] strict Argument passed to `resolve_post_source_directory()`.
8871 @return Value returned by `resolve_post_source_directory()`.
8873 solver_output_dir_rel = monitor_cfg.get(
'io', {}).get(
'directories', {}).get(
'output',
'output')
8874 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
8876 if source_dir_template ==
'<solver_output_dir>':
8877 resolved_source_dir = solver_output_dir_abs
8878 print(f
"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
8880 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
8881 print(f
"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
8883 if strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
8885 f
"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
8889 if not strict
and (
not os.path.isdir(resolved_source_dir)
or not os.listdir(resolved_source_dir)):
8890 print(
"[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
8891 return resolved_source_dir
8898 case_index_tsv: str,
8906 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
8907 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
8908 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
8909 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
8910 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
8911 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
8912 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
8913 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
8914 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
8915 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
8916 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
8917 @return Value returned by `render_slurm_array_stage_script()`.
8920 resources = effective_cluster_cfg.get(
"resources", {})
8921 notifications = effective_cluster_cfg.get(
"notifications", {})
or {}
8922 execution = effective_cluster_cfg.get(
"execution", {})
or {}
8923 module_setup = execution.get(
"module_setup", [])
or []
8924 extra_sbatch = execution.get(
"extra_sbatch")
8928 f
"#SBATCH --job-name={job_name}",
8929 f
"#SBATCH --nodes={resources['nodes']}",
8930 f
"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
8931 f
"#SBATCH --mem={resources['mem']}",
8932 f
"#SBATCH --time={resources['time']}",
8933 f
"#SBATCH --output={stdout_path}",
8934 f
"#SBATCH --error={stderr_path}",
8935 f
"#SBATCH --account={resources['account']}",
8936 f
"#SBATCH --array={array_spec}",
8938 partition = resources.get(
"partition")
8940 lines.append(f
"#SBATCH --partition={partition}")
8941 mail_user = notifications.get(
"mail_user")
8942 mail_type = notifications.get(
"mail_type")
8944 lines.append(f
"#SBATCH --mail-user={mail_user}")
8946 lines.append(f
"#SBATCH --mail-type={mail_type}")
8947 if isinstance(extra_sbatch, dict):
8948 for key, value
in extra_sbatch.items():
8950 if not flag.startswith(
"--"):
8952 if isinstance(value, bool):
8954 lines.append(f
"#SBATCH {flag}")
8955 elif value
is not None:
8956 lines.append(f
"#SBATCH {flag}={value}")
8957 elif isinstance(extra_sbatch, list):
8958 for token
in extra_sbatch:
8959 lines.append(f
"#SBATCH {token}")
8963 "set -euo pipefail",
8965 f
'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
8966 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
8967 'if [ -z "$LINE" ]; then',
8968 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
8971 "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\"",
8973 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
8976 if stage ==
"solve":
8978 for key, value
in walltime_guard_exports.items():
8979 lines.append(f
"export {key}={value}")
8981 lines.append(
'export LOG_LEVEL="${LOG_LEVEL}"')
8983 for setup_line
in module_setup:
8984 lines.append(str(setup_line))
8986 if stage ==
"solve":
8988 effective_cluster_cfg,
8990 [
"-control_file",
"$CONTROL_FILE"]
8994 effective_cluster_cfg,
8996 [
"-control_file",
"$CONTROL_FILE",
"-postprocessing_config_file",
"$POST_RECIPE_FILE"],
9001 def _token(tok: str) -> str:
9003 @brief Perform token.
9004 @param[in] tok Argument passed to `_token()`.
9005 @return Value returned by `_token()`.
9007 if tok.startswith(
"$"):
9009 return shlex.quote(str(tok))
9011 diag_var =
"${SOLVE_DIAGNOSTIC_ARGS}" if stage ==
"solve" else "${POST_DIAGNOSTIC_ARGS}"
9012 command_text =
" ".join(_token(t)
for t
in cmd)
9013 executable_token = _token(solver_exe
if stage ==
"solve" else post_exe)
9016 if executable_token
and command_text.count(executable_token) == 1:
9017 command_text = command_text.replace(f
"{executable_token} ", f
"{executable_token} {diag_var} ", 1)
9018 lines.append(f
"exec {command_text}")
9020 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
9021 with open(script_path,
"w")
as f:
9022 f.write(
"\n".join(lines) +
"\n")
9023 os.chmod(script_path, 0o755)
9034 @brief Generate a single-node sbatch script that runs metrics aggregation.
9035 @param[in] script_path Path to write the sbatch script.
9036 @param[in] job_name Slurm job name.
9037 @param[in] cluster_cfg Parsed cluster YAML dictionary.
9038 @param[in] study_dir Absolute path to the study directory.
9039 @param[in] picurv_path Absolute path to the picurv script.
9041 resources = cluster_cfg.get(
"resources", {})
9042 notifications = cluster_cfg.get(
"notifications", {})
or {}
9043 execution = cluster_cfg.get(
"execution", {})
or {}
9044 module_setup = execution.get(
"module_setup", [])
or []
9046 scheduler_dir = os.path.join(study_dir,
"scheduler")
9049 f
"#SBATCH --job-name={job_name}",
9050 "#SBATCH --nodes=1",
9051 "#SBATCH --ntasks-per-node=1",
9053 "#SBATCH --time=00:10:00",
9054 f
"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
9055 f
"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
9056 f
"#SBATCH --account={resources['account']}",
9058 partition = resources.get(
"partition")
9060 lines.append(f
"#SBATCH --partition={partition}")
9061 mail_user = notifications.get(
"mail_user")
9062 mail_type = notifications.get(
"mail_type")
9064 lines.append(f
"#SBATCH --mail-user={mail_user}")
9066 lines.append(f
"#SBATCH --mail-type={mail_type}")
9070 "set -euo pipefail",
9071 'echo "[$(date)] Running metrics aggregation"',
9074 for setup_line
in module_setup:
9075 lines.append(str(setup_line))
9078 f
"exec {shlex.quote(picurv_path)} sweep --reaggregate"
9079 f
" --study-dir {shlex.quote(study_dir)}"
9082 os.makedirs(os.path.dirname(script_path), exist_ok=
True)
9083 with open(script_path,
"w")
as f:
9084 f.write(
"\n".join(lines) +
"\n")
9085 os.chmod(script_path, 0o755)
9090 @brief Reduce a metric series to one scalar according to the requested reducer.
9091 @param[in] values Sequence of numeric values.
9092 @param[in] reduction Reduction keyword.
9093 @return Value returned by `reduce_metric_values()`.
9098 reduction = str(reduction).lower()
9099 if reduction ==
"mean":
9100 return float(np.mean(values))
9101 if reduction ==
"min":
9102 return float(np.min(values))
9103 if reduction ==
"max":
9104 return float(np.max(values))
9105 if reduction ==
"p95":
9106 return float(np.percentile(values, 95.0))
9107 return float(values[-1])
9112 @brief Extract a scalar metric from a CSV source.
9113 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
9114 @param[in] spec Argument passed to `extract_metric_from_csv()`.
9115 @return Value returned by `extract_metric_from_csv()`.
9117 file_glob = spec.get(
"file_glob",
"**/*_msd.csv")
9118 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
9121 csv_path = candidates[0]
9123 with open(csv_path,
"r", newline=
"")
as f:
9124 reader = csv.DictReader(f)
9125 if reader.fieldnames:
9130 column = spec.get(
"column")
9131 numerator_column = spec.get(
"numerator_column")
9132 denominator_column = spec.get(
"denominator_column")
9133 denominator_floor = float(spec.get(
"denominator_floor", 0.0)
or 0.0)
9134 if not column
and not numerator_column:
9135 for name
in reversed(reader.fieldnames):
9136 if name
and name.lower()
not in {
"step",
"time",
"timestep"}:
9139 if not column
and not numerator_column:
9144 if numerator_column:
9145 numerator = float(row[numerator_column])
9146 denominator = float(row[denominator_column])
9147 denominator = max(denominator_floor, denominator)
9148 if denominator == 0.0:
9150 values.append(numerator / denominator)
9152 values.append(float(row[column]))
9162 @brief Extract a scalar metric from a log file using regex.
9163 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
9164 @param[in] spec Argument passed to `extract_metric_from_log()`.
9165 @return Value returned by `extract_metric_from_log()`.
9167 file_glob = spec.get(
"file_glob",
"logs/*.log")
9168 regex = spec.get(
"regex")
9171 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=
True))
9174 pattern = re.compile(regex)
9176 for path
in candidates:
9178 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
9180 m = pattern.search(line)
9183 values.append(float(m.group(1)))
9193 @brief Normalize study metric definitions to a common dictionary form.
9194 @param[in] metric Argument passed to `normalize_metric_spec()`.
9195 @return Value returned by `normalize_metric_spec()`.
9197 if isinstance(metric, str):
9198 if metric.lower()
in {
"msd",
"msd_final"}:
9200 "name":
"msd_final",
9201 "source":
"statistics_csv",
9202 "file_glob":
"**/*_msd.csv",
9203 "reduction":
"last",
9205 return {
"name": metric,
"source":
"log_regex",
"regex": metric}
9210 @brief Collect metric values from generated case directories into one CSV.
9211 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
9212 @param[in] cases Argument passed to `aggregate_study_metrics()`.
9213 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
9214 @return Value returned by `aggregate_study_metrics()`.
9216 metrics = study_cfg.get(
"metrics", [])
9218 metrics = [
"msd_final"]
9223 row = {
"case_id": case[
"case_id"]}
9224 for p_key, p_val
in case[
"parameters"].items():
9226 for spec
in normalized_specs:
9227 name = spec.get(
"name",
"metric")
9228 source = str(spec.get(
"source",
"")).lower()
9229 if source
in {
"statistics_csv",
"csv"}:
9231 elif source
in {
"log_regex",
"log"}:
9236 normalize_key = spec.get(
"normalize_by_parameter")
9237 if value
is not None and normalize_key:
9238 denom = case.get(
"parameters", {}).get(normalize_key)
9240 denom = float(denom)
9243 if denom
not in (
None, 0.0):
9244 value = float(value) / denom
9257 for k
in row.keys():
9262 os.makedirs(results_dir, exist_ok=
True)
9263 out_csv = os.path.join(results_dir,
"metrics_table.csv")
9264 with open(out_csv,
"w", newline=
"")
as f:
9265 writer = csv.DictWriter(f, fieldnames=all_keys)
9266 writer.writeheader()
9267 writer.writerows(rows)
9268 print(f
"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
9273 @brief Infer x-axis key/values for study plots.
9274 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
9275 @param[in] rows Argument passed to `infer_plot_x_axis()`.
9276 @return Value returned by `infer_plot_x_axis()`.
9279 if not params
or not rows:
9282 study_type = study_cfg.get(
"study_type")
9283 if study_type ==
"grid_independence":
9284 has_im =
"case.grid.programmatic_settings.im" in params
9285 has_jm =
"case.grid.programmatic_settings.jm" in params
9286 has_km =
"case.grid.programmatic_settings.km" in params
9287 if has_im
and has_jm
and has_km:
9291 im = float(row[
"case.grid.programmatic_settings.im"])
9292 jm = float(row[
"case.grid.programmatic_settings.jm"])
9293 km = float(row[
"case.grid.programmatic_settings.km"])
9294 xs.append((im * jm * km) ** (1.0 / 3.0))
9297 return "N^(1/3)", xs
9303 xs.append(float(row[primary]))
9310 @brief Generate metric-vs-parameter plots for completed studies.
9311 @param[in] study_cfg Argument passed to `generate_study_plots()`.
9312 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
9313 @param[in] plots_dir Argument passed to `generate_study_plots()`.
9314 @return Value returned by `generate_study_plots()`.
9316 plotting_cfg = study_cfg.get(
"plotting", {})
or {}
9317 if plotting_cfg.get(
"enabled",
True)
is False:
9318 print(
"[INFO] Plotting disabled by study.yml.")
9322 print(
"[WARNING] matplotlib not available; skipping plot generation.")
9324 if not metrics_csv
or not os.path.isfile(metrics_csv):
9327 with open(metrics_csv,
"r", newline=
"")
as f:
9328 reader = csv.DictReader(f)
9334 if not x_name
or x_values
is None:
9335 print(
"[WARNING] Could not infer numeric x-axis for plots; skipping.")
9340 for key
in rows[0].keys():
9341 if key
in {
"case_id"}:
9343 if key
in param_keys:
9345 metric_keys.append(key)
9347 out_format = plotting_cfg.get(
"output_format",
"png")
9348 os.makedirs(plots_dir, exist_ok=
True)
9350 for metric
in metric_keys:
9355 y_values.append(float(row[metric]))
9361 plt.figure(figsize=(7.0, 4.2))
9362 plt.plot(x_values, y_values, marker=
"o", linewidth=1.5)
9365 plt.title(f
"{metric} vs {x_name}")
9366 plt.grid(
True, alpha=0.3)
9367 out_path = os.path.join(plots_dir, f
"{metric}_vs_{x_name.replace('/', '_')}.{out_format}")
9369 plt.savefig(out_path, dpi=150)
9371 generated.append(out_path)
9373 print(f
"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
9379 @brief Render a command list as a shell-safe display string.
9380 @param[in] command_tokens Argument passed to `_command_to_string()`.
9381 @return Value returned by `_command_to_string()`.
9383 return " ".join(shlex.quote(str(tok))
for tok
in command_tokens)
9388 @brief Resolve post source directory without side effects or stdout/stderr output.
9389 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
9390 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9391 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9392 @return Value returned by `_resolve_post_source_directory_preview()`.
9394 solver_output_dir_rel = monitor_cfg.get(
'io', {}).get(
'directories', {}).get(
'output',
'output')
9395 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
9397 if source_dir_template ==
'<solver_output_dir>':
9398 return solver_output_dir_abs
9399 return os.path.abspath(os.path.join(run_dir, source_dir_template))
9404 @brief Build a no-write execution plan for `run --dry-run`.
9405 @param[in] args Command-line style argument list supplied to the function.
9406 @return Value returned by `build_run_dry_plan()`.
9410 "created_at": datetime.now().isoformat(),
9417 if args.dry_run
and args.no_submit:
9418 plan[
"warnings"].append(
"--dry-run takes precedence over --no-submit; no files will be written.")
9420 cluster_mode = bool(getattr(args,
"cluster",
None))
9423 solver_num_procs_effective = args.num_procs
9424 post_num_procs_effective = 1
9427 solver_control_path =
None
9428 loaded_case_cfg =
None
9429 loaded_monitor_cfg =
None
9430 resolved_restart_source_dir =
None
9433 cluster_path = os.path.abspath(args.cluster)
9436 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
9437 if args.scheduler
and args.scheduler.lower() != scheduler_type:
9439 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9440 key=
"scheduler.type",
9441 file_path=cluster_path,
9442 message=f
"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
9445 if scheduler_type !=
"slurm":
9447 ERROR_CODE_CFG_INVALID_VALUE,
9448 key=
"scheduler.type",
9449 file_path=cluster_path,
9450 message=f
"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.",
9454 if args.solve
and args.num_procs
not in (1, cluster_tasks):
9456 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9457 key=
"resources.ntasks_per_node",
9458 file_path=cluster_path,
9460 "--num-procs applies to the solver stage and must be 1 (auto) or "
9461 f
"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
9466 solver_num_procs_effective = cluster_tasks
9467 plan[
"launch_mode"] =
"slurm"
9468 plan[
"inputs"][
"cluster"] = cluster_path
9470 if getattr(args,
"scheduler",
None):
9471 fail_cli_usage(
"--scheduler requires --cluster in this version.")
9472 plan[
"launch_mode"] =
"local"
9476 if getattr(args,
'restart_from',
None):
9477 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
9478 if getattr(args,
'continue_run',
False)
and not args.post_process:
9479 print(
"[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
9482 case_path = os.path.abspath(args.case)
9483 solver_path = os.path.abspath(args.solver)
9484 monitor_path = os.path.abspath(args.monitor)
9488 validate_solver_configs(loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path)
9490 continue_mode = getattr(args,
'continue_run',
False)
9494 if not args.run_dir:
9496 run_dir = os.path.abspath(args.run_dir)
9497 if not os.path.isdir(run_dir):
9499 ERROR_CODE_CFG_FILE_NOT_FOUND,
9502 message=
"Specified run directory not found.",
9505 run_id = os.path.basename(run_dir)
9507 case_name = os.path.splitext(os.path.basename(case_path))[0]
9508 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
9509 run_id = f
"{case_name}_{timestamp}"
9510 run_dir = os.path.abspath(os.path.join(
"runs", run_id))
9514 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir
9516 except ValueError
as e:
9518 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9520 file_path=case_path,
9525 config_dir = os.path.join(run_dir,
"config")
9526 scheduler_dir = os.path.join(run_dir,
"scheduler")
9527 logs_dir = os.path.join(run_dir,
"logs")
9528 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
9529 profile_path = os.path.join(config_dir,
"profile.run")
9532 plan[
"run_id_preview"] = run_id
9533 plan[
"run_dir_preview"] = run_dir
9534 plan[
"inputs"].update({
"case": case_path,
"solver": solver_path,
"monitor": monitor_path})
9535 plan[
"artifacts"].extend(
9540 os.path.join(run_dir,
"output"),
9542 os.path.join(config_dir,
"case.yml"),
9543 os.path.join(config_dir,
"solver.yml"),
9544 os.path.join(config_dir,
"monitor.yml"),
9545 solver_control_path,
9546 os.path.join(run_dir,
"manifest.json"),
9553 plan[
"artifacts"].append(os.path.join(config_dir,
"whitelist.run"))
9554 if profiling_preview[
"mode"] ==
"selected":
9555 plan[
"artifacts"].append(profile_path)
9557 plan[
"artifacts"].extend(solve_diagnostics[
"artifacts"])
9559 plan[
"artifacts"].append(os.path.join(config_dir,
"cluster.yml"))
9560 plan[
"artifacts"].append(os.path.join(scheduler_dir,
"submission.json"))
9565 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
9570 config_search_anchor=case_path,
9571 extra_search_anchors=[cluster_path],
9573 plan[
"artifacts"].append(solver_script)
9574 plan[
"stages"][
"solve"] = {
9576 "script": solver_script,
9577 "num_procs_effective": solver_num_procs_effective,
9578 "launch_command": solver_cmd,
9585 solver_num_procs_effective,
9586 config_search_anchor=case_path,
9588 solver_stream_log = os.path.join(scheduler_dir, f
"{run_id}_solver.log")
9589 plan[
"artifacts"].append(solver_stream_log)
9590 plan[
"stages"][
"solve"] = {
9592 "num_procs_effective": solver_num_procs_effective,
9593 "stream_log": solver_stream_log,
9594 "launch_command": solver_cmd,
9597 if resolved_restart_source_dir:
9598 plan[
"stages"][
"solve"][
"restart_source_directory"] = resolved_restart_source_dir
9600 plan[
"stages"][
"solve"][
"continue_mode"] =
True
9602 if args.post_process:
9603 post_path = os.path.abspath(args.post)
9604 plan[
"inputs"][
"post"] = post_path
9609 run_dir = os.path.abspath(args.run_dir)
9610 if not os.path.isdir(run_dir):
9612 ERROR_CODE_CFG_FILE_NOT_FOUND,
9615 message=
"Specified run directory not found.",
9618 run_id = os.path.basename(run_dir)
9619 elif not args.solve:
9620 fail_cli_usage(
"--post-process requires --run-dir when not used with --solve.")
9623 config_dir = os.path.join(run_dir,
"config")
9625 if not all([case_path, monitor_path, solver_control_path]):
9627 ERROR_CODE_CFG_MISSING_KEY,
9628 key=
"run_dir.config",
9629 file_path=config_dir,
9631 "Could not auto-identify required run inputs "
9632 "(case.yml/monitor.yml/*.control) in run config directory."
9639 config_dir = os.path.join(run_dir,
"config")
9640 case_path = os.path.join(config_dir,
"case.yml")
9641 monitor_path = os.path.join(config_dir,
"monitor.yml")
9642 if solver_control_path
is None:
9643 solver_control_path = os.path.join(config_dir, f
"{run_id}.control")
9645 allow_source_frontier_scan =
not args.solve
9652 continue_requested=getattr(args,
'continue_run',
False),
9653 allow_source_frontier_scan=allow_source_frontier_scan,
9656 post_recipe_path = os.path.join(config_dir,
"post.run")
9657 output_dir_rel = post_cfg.get(
"io", {}).get(
"output_directory")
9658 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix")
9659 if not output_dir_rel
or not output_prefix:
9661 ERROR_CODE_CFG_MISSING_KEY,
9662 key=
"io.output_directory/io.output_filename_prefix",
9663 file_path=post_path,
9664 message=
"Missing required post IO keys.",
9667 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
9671 plan[
"artifacts"].extend(post_diagnostics[
"artifacts"])
9674 solver_control_path,
9675 "-postprocessing_config_file",
9678 plan[
"artifacts"].extend([
9681 post_plan[
"resume_state_path"],
9682 post_plan[
"lock_paths"][
"wrapper_path"],
9683 post_plan[
"lock_paths"][
"lock_file"],
9684 post_plan[
"lock_paths"][
"metadata_file"],
9686 plan[
"artifacts"].extend(statistics_output_paths)
9689 "source_data_directory": post_plan[
"source_data_directory"],
9690 "requested_start_step": post_plan[
"requested_start_step"],
9691 "requested_end_step": post_plan[
"requested_end_step"],
9692 "step_interval": post_plan[
"step_interval"],
9693 "resume_applied": bool(post_plan[
"continue_requested"]
and post_plan[
"resume_recipe_match"]),
9694 "resume_recipe_match": post_plan[
"resume_recipe_match"],
9695 "resume_bootstrapped": post_plan[
"resume_bootstrapped"],
9696 "resume_match_source": post_plan[
"resume_match_source"],
9697 "completed_frontier_step": post_plan[
"completed_frontier_step"],
9698 "source_frontier_step": post_plan[
"source_frontier_step"],
9699 "source_frontier_diagnostic": post_plan[
"source_frontier_diagnostic"],
9700 "source_frontier_deferred": post_plan[
"source_frontier_deferred"],
9701 "effective_start_step": post_plan[
"effective_start_step"],
9702 "effective_end_step": post_plan[
"effective_end_step"],
9703 "skip_reason": post_plan[
"skip_reason"],
9704 "post_skipped_as_complete": post_plan[
"skip_reason"] ==
"already-complete-window",
9705 "recipe_fingerprint": post_plan[
"recipe_fingerprint"],
9706 "num_procs_effective": post_num_procs_effective,
9709 if post_plan[
"skip_reason"]
is None:
9711 scheduler_dir = os.path.join(run_dir,
"scheduler")
9712 post_script = os.path.join(scheduler_dir,
"post.sbatch")
9718 config_search_anchor=case_path,
9719 extra_search_anchors=[cluster_path],
9720 force_num_procs=post_num_procs_effective,
9724 post_plan[
"recipe_fingerprint"],
9726 create_wrapper=
False,
9728 plan[
"artifacts"].append(post_script)
9731 "script": post_script,
9732 "launch_command": post_cmd,
9739 post_num_procs_effective,
9740 config_search_anchor=case_path,
9741 allow_single_rank_launcher_override=
True,
9742 force_num_procs=post_num_procs_effective,
9746 post_plan[
"recipe_fingerprint"],
9748 create_wrapper=
False,
9750 post_stream_log = os.path.join(run_dir,
"scheduler", f
"{run_id}_{output_prefix}.log")
9751 plan[
"artifacts"].append(post_stream_log)
9754 "stream_log": post_stream_log,
9755 "launch_command": post_cmd,
9760 "mode":
"slurm" if cluster_mode
else "local",
9761 "launch_command": [],
9762 "launch_command_string":
"",
9765 plan[
"stages"][
"post-process"] = stage_meta
9770 for item
in plan[
"artifacts"]:
9771 if item
not in seen:
9773 deduped.append(item)
9774 plan[
"artifacts"] = deduped
9775 if run_id
and "run_id_preview" not in plan:
9776 plan[
"run_id_preview"] = run_id
9777 if run_dir
and "run_dir_preview" not in plan:
9778 plan[
"run_dir_preview"] = run_dir
9779 plan[
"solver_num_procs_effective"] = solver_num_procs_effective
9780 plan[
"post_num_procs_effective"] = post_num_procs_effective
9781 plan[
"num_procs_effective"] = solver_num_procs_effective
9787 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
9788 @param[in,out] plan Dry-run plan to update.
9789 @param[in] case_cfg Parsed case configuration.
9790 @param[in] run_dir Preview run directory for relative artifact resolution.
9792 grid_cfg = case_cfg.get(
"grid", {})
9793 if not isinstance(grid_cfg, dict):
9796 mode = grid_cfg.get(
"mode")
9797 config_dir = os.path.join(run_dir,
"config")
9800 plan[
"artifacts"].append(os.path.join(config_dir,
"grid.run"))
9801 legacy_cfg = grid_cfg.get(
"legacy_conversion")
9802 if isinstance(legacy_cfg, dict)
and legacy_cfg.get(
"enabled",
True):
9803 output_file = legacy_cfg.get(
"output_file", os.path.join(
"config",
"grid.converted.picgrid"))
9804 if isinstance(output_file, str)
and output_file.strip():
9805 if not os.path.isabs(output_file):
9806 output_file = os.path.abspath(os.path.join(run_dir, output_file))
9807 plan[
"artifacts"].append(output_file)
9808 elif mode ==
"grid_gen":
9809 generator = grid_cfg.get(
"generator", {})
9810 if not isinstance(generator, dict):
9812 plan[
"artifacts"].append(os.path.join(config_dir,
"grid.run"))
9813 for key, default
in (
9814 (
"output_file", os.path.join(
"config",
"grid.generated.picgrid")),
9815 (
"stats_file",
None),
9818 artifact_path = generator.get(key, default)
9819 if isinstance(artifact_path, str)
and artifact_path.strip():
9820 if not os.path.isabs(artifact_path):
9821 artifact_path = os.path.abspath(os.path.join(run_dir, artifact_path))
9822 plan[
"artifacts"].append(artifact_path)
9826 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
9827 @param[in,out] plan Dry-run plan to update.
9828 @param[in] case_cfg Parsed case configuration.
9829 @param[in] run_dir Preview run directory.
9835 config_dir = os.path.join(run_dir,
"config")
9836 has_generated =
False
9837 for block_idx, block
in enumerate(prepared_blocks):
9839 if bc.get(
"handler") !=
"prescribed_flow":
9841 source = (bc.get(
"params")
or {}).get(
"source", {})
9842 if source.get(
"type")
not in {
"generated",
"field_slice"}:
9844 has_generated =
True
9846 suffix =
"generated" if source.get(
"type") ==
"generated" else "sliced"
9847 default_output = os.path.join(
9848 "config", f
"inlet_profile_block{block_idx}_{face_token}.{suffix}.picslice"
9852 source.get(
"output_file"),
9854 default_to_config_dir=
True,
9856 staged_path = os.path.join(config_dir, f
"inlet_profile_block{block_idx}_{face_token}.picslice")
9857 plan[
"artifacts"].append(generated_path)
9858 plan[
"artifacts"].append(staged_path)
9860 plan[
"artifacts"].append(os.path.join(config_dir,
"profile.info"))
9864 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
9865 @param[in,out] plan Dry-run plan receiving artifact paths.
9866 @param[in] case_cfg Parsed case configuration.
9867 @param[in] solver_cfg Parsed solver configuration.
9868 @param[in] run_dir Planned run directory.
9871 (solver_cfg.get(
"operation_mode", {})
or {}).get(
"eulerian_field_source",
"solve")
9873 start_step = int((case_cfg.get(
"run_control", {})
or {}).get(
"start_step", 0)
or 0)
9874 if source !=
"solve" or start_step != 0:
9878 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
9882 except (KeyError, ValueError):
9884 if resolved[
"kind"]
not in {
"file",
"ic_gen"}:
9886 config_dir = os.path.join(run_dir,
"config")
9887 plan[
"artifacts"].append(
9888 os.path.join(config_dir,
"initial_condition", f
"{resolved['field_name']}00000_0.dat")
9890 if resolved[
"kind"] ==
"ic_gen":
9892 run_dir, resolved.get(
"output_file"), os.path.join(
"config",
"initial_condition.generated.dat"),
9893 default_to_config_dir=
True,
9899 @brief Render dry-run plan in human or JSON format.
9900 @param[in] plan Argument passed to `render_run_dry_plan()`.
9901 @param[in] output_format Argument passed to `render_run_dry_plan()`.
9903 if output_format ==
"json":
9904 print(json.dumps(plan, indent=2, sort_keys=
True))
9907 print(
"\n" +
"=" * 60)
9908 print(
" DRY-RUN PLAN")
9910 print(f
" Launch mode : {plan.get('launch_mode')}")
9911 print(f
" Created at : {plan.get('created_at')}")
9912 if plan.get(
"run_id_preview"):
9913 print(f
" Run ID preview : {plan.get('run_id_preview')}")
9914 if plan.get(
"run_dir_preview"):
9915 print(f
" Run dir preview: {plan.get('run_dir_preview')}")
9916 print(f
" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
9917 print(f
" Post MPI procs : {plan.get('post_num_procs_effective')}")
9918 if plan.get(
"warnings"):
9919 print(
" Warnings :")
9920 for warning
in plan[
"warnings"]:
9921 print(f
" - {warning}")
9923 if plan.get(
"inputs"):
9925 for key, value
in plan[
"inputs"].items():
9926 print(f
" - {key}: {value}")
9928 if plan.get(
"stages"):
9929 print(
"\n Planned stage commands:")
9930 for stage, details
in plan[
"stages"].items():
9931 print(f
" - {stage} ({details.get('mode')}):")
9932 if details.get(
'skip_reason'):
9933 print(f
" skipped: {details.get('skip_reason')}")
9935 print(f
" {details.get('launch_command_string')}")
9937 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"]
9938 if diagnostics_artifacts:
9939 print(
"\n Diagnostics artifacts:")
9940 for artifact
in diagnostics_artifacts:
9941 print(f
" - {artifact}")
9943 print(
"\n Planned artifacts (no files created in dry-run):")
9944 for artifact
in plan.get(
"artifacts", []):
9945 print(f
" - {artifact}")
9951 @brief Implements `picurv validate` without launching solver/post workflows.
9952 @param[in] args Command-line style argument list supplied to the function.
9955 solver_group_selected = any([args.case, args.solver, args.monitor])
9956 any_group_selected = solver_group_selected
or any([args.post, args.cluster, args.study])
9960 if not any_group_selected:
9962 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
9963 hint=
"Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
9966 if solver_group_selected
and not all([args.case, args.solver, args.monitor]):
9967 fail_cli_usage(
"When solver validation is requested, --case, --solver, and --monitor are all required.")
9970 restart_from = getattr(args,
'restart_from',
None)
9971 continue_run = getattr(args,
'continue_run',
False)
9972 run_dir_val = getattr(args,
'run_dir',
None)
9973 if not solver_group_selected:
9975 print(
"[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
9976 if continue_run
and not args.post:
9977 print(
"[WARNING] --continue has no effect without solver configs or --post and will be ignored.", file=sys.stderr)
9978 if continue_run
and not run_dir_val:
9981 if solver_group_selected:
9982 case_path = os.path.abspath(args.case)
9983 solver_path = os.path.abspath(args.solver)
9984 monitor_path = os.path.abspath(args.monitor)
9989 checked.extend([case_path, solver_path, monitor_path])
9992 if restart_from
or continue_run:
9993 target_run_dir = os.path.abspath(run_dir_val)
if run_dir_val
else os.path.abspath(
"runs/_validate_dummy")
9996 print(
"[SUCCESS] Restart source validation passed.")
9997 except ValueError
as e:
9998 print(f
"[ERROR] Restart validation failed: {e}", file=sys.stderr)
10003 post_path = os.path.abspath(args.post)
10006 checked.append(post_path)
10010 cluster_path = os.path.abspath(args.cluster)
10013 checked.append(cluster_path)
10018 extra_search_anchors=[cluster_path]
if cluster_path
else None,
10020 except ValueError
as exc:
10022 ERROR_CODE_CFG_INVALID_VALUE,
10023 key=
"runtime_execution",
10024 file_path=case_path
or cluster_path
or os.getcwd(),
10028 if runtime_execution_path:
10029 checked.append(runtime_execution_path)
10033 study_path = os.path.abspath(args.study)
10036 checked.append(study_path)
10038 if post_cfg
is not None and run_dir_val:
10039 post_path = os.path.abspath(args.post)
10040 validate_run_dir = os.path.abspath(run_dir_val)
10041 if os.path.isdir(validate_run_dir):
10042 monitor_for_post = monitor_cfg
if solver_group_selected
else None
10043 if monitor_for_post
is None:
10044 config_dir_candidate = os.path.join(validate_run_dir,
"config")
10045 monitor_candidate = os.path.join(config_dir_candidate,
"monitor.yml")
10046 if os.path.isfile(monitor_candidate):
10048 if monitor_for_post
is not None:
10050 if os.path.isdir(resolved_source)
and os.listdir(resolved_source):
10051 print(f
"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
10053 print(f
"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
10055 if args.strict
and post_cfg
is not None:
10056 post_path = os.path.abspath(args.post)
10057 source_dir = post_cfg.get(
"source_data", {}).get(
"directory")
10058 if source_dir
and source_dir !=
"<solver_output_dir>":
10060 if not os.path.isdir(resolved):
10062 ERROR_CODE_CFG_FILE_NOT_FOUND,
10063 key=
"source_data.directory",
10064 file_path=post_path,
10065 message=f
"strict mode: source_data.directory resolves to missing directory '{resolved}'.",
10069 if args.strict
and study_cfg
is not None:
10070 study_path = os.path.abspath(args.study)
10071 base_cfgs = study_cfg.get(
"base_configs", {})
10072 if isinstance(base_cfgs, dict):
10073 base_case_path =
resolve_path(study_path, base_cfgs.get(
"case"))
10074 base_solver_path =
resolve_path(study_path, base_cfgs.get(
"solver"))
10075 base_monitor_path =
resolve_path(study_path, base_cfgs.get(
"monitor"))
10076 base_post_path =
resolve_path(study_path, base_cfgs.get(
"post"))
10077 if all([base_case_path, base_solver_path, base_monitor_path]):
10089 print(f
"[SUCCESS] Validation completed for {len(checked)} file(s).")
10090 for path
in checked:
10091 print(f
" - {path}")
10095 @brief Generate deterministic case artifacts without launching solver/post stages.
10096 @param[in] args Parsed precompute command arguments.
10098 case_path = os.path.abspath(args.case)
10100 case_name = os.path.splitext(os.path.basename(case_path))[0]
10101 output_dir = args.output_dir
or os.path.join(
"precomputed", case_name)
10102 output_dir = os.path.abspath(output_dir)
10103 config_dir = os.path.join(output_dir,
"config")
10104 os.makedirs(config_dir, exist_ok=
True)
10106 print(f
"[INFO] Precomputing deterministic artifacts for case: {case_path}")
10107 print(f
"[INFO] Output directory: {output_dir}")
10111 grid_cfg = case_cfg.get(
"grid", {})
or {}
10112 grid_mode = grid_cfg.get(
"mode")
10113 scaling = (case_cfg.get(
"properties", {})
or {}).get(
"scaling", {})
or {}
10114 length_ref = float(scaling.get(
"length_ref", 1.0))
10115 expected_nblk = int((case_cfg.get(
"models", {})
or {}).get(
"domain", {}).get(
"blocks", 1))
10116 staged_grid = os.path.join(config_dir,
"grid.run")
10117 if grid_mode ==
"grid_gen":
10118 print(
"[INFO] Precomputing grid via grid.gen...")
10120 artifacts.append(os.path.abspath(generated_grid))
10122 artifacts.append(os.path.abspath(staged_grid))
10123 elif grid_mode ==
"file":
10125 if isinstance(grid_cfg.get(
"legacy_conversion"), dict):
10128 artifacts.append(os.path.abspath(staged_grid))
10129 print(f
"[INFO] Precomputed validated file grid: {staged_grid}")
10130 elif grid_mode ==
"programmatic_c":
10131 print(f
"[INFO] Grid mode '{grid_mode}' does not require precomputed grid generation.")
10133 raise ValueError(f
"Unsupported grid.mode '{grid_mode}' for precompute.")
10136 artifacts.extend(summary[
"path"]
for summary
in profile_summaries)
10137 if profile_summaries:
10138 artifacts.append(os.path.join(config_dir,
"profile.info"))
10140 initial_condition =
None
10142 (case_cfg.get(
"properties", {})
or {}).get(
"initial_conditions", {}),
10144 U_ref=float((case_cfg.get(
"properties", {}).get(
"scaling", {})
or {}).get(
"velocity_ref", 1.0)),
10146 if resolved_ic[
"kind"] ==
"ic_gen":
10147 if grid_mode ==
"programmatic_c":
10150 grid_cfg.get(
'programmatic_settings', {}), staged_grid, length_ref
10152 artifacts.append(os.path.abspath(staged_grid))
10154 f
"[INFO] Materialized programmatic grid.run for ic_gen: {staged_grid} "
10155 f
"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
10157 except Exception
as e:
10158 raise RuntimeError(f
"Failed to generate grid.run for ic_gen: {e}")
from e
10160 artifacts.extend([initial_condition[
"source"], initial_condition[
"staged"]])
10161 elif resolved_ic[
"kind"] ==
"file":
10162 print(
"[INFO] File initial condition does not require generated precompute output.")
10164 print(f
"[INFO] Built-in initial-condition generator '{resolved_ic['label']}' runs in the C solver.")
10168 "output_dir": output_dir,
10169 "grid_mode": grid_mode,
10170 "artifacts": artifacts,
10171 "profiles": profile_summaries,
10172 "initial_condition": initial_condition,
10174 manifest_path = os.path.join(config_dir,
"precompute.manifest.json")
10176 print(f
"[SUCCESS] Wrote precompute manifest: {os.path.relpath(manifest_path)}")
10177 print(f
"[SUCCESS] Precompute completed with {len(artifacts)} artifact(s).")
10181 @brief Main orchestrator for the 'run' command (local and Slurm modes).
10182 @param[in] args Command-line style argument list supplied to the function.
10184 if getattr(args,
"dry_run",
False):
10191 output_dir_abs =
None
10192 statistics_output_paths = []
10193 workflow_start = time.time()
10194 stages_completed = []
10196 submission_meta = {
"launch_mode":
"local",
"no_submit": bool(args.no_submit),
"stages": {}}
10198 cluster_mode = bool(getattr(args,
"cluster",
None))
10200 cluster_path =
None
10201 solver_num_procs_effective = args.num_procs
10202 post_num_procs_effective = 1
10205 cluster_path = os.path.abspath(args.cluster)
10208 scheduler_type = str(cluster_cfg.get(
"scheduler", {}).get(
"type",
"slurm")).lower()
10209 if args.scheduler
and args.scheduler.lower() != scheduler_type:
10211 f
"[FATAL] --scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
10215 if scheduler_type !=
"slurm":
10216 print(f
"[FATAL] Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.", file=sys.stderr)
10219 if args.solve
and args.num_procs
not in (1, cluster_tasks):
10221 "[FATAL] In cluster mode, --num-procs applies to the solver stage and must be "
10222 f
"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
10227 solver_num_procs_effective = cluster_tasks
10228 submission_meta[
"launch_mode"] =
"slurm"
10229 submission_meta[
"cluster_config"] = cluster_path
10230 submission_meta[
"no_submit"] = bool(args.no_submit)
10233 f
"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks "
10234 f
"from cluster.yml; post stage defaults to {post_num_procs_effective} task."
10237 print(f
"[INFO] Cluster mode enabled (Slurm). Post stage defaults to {post_num_procs_effective} task.")
10238 elif getattr(args,
"scheduler",
None):
10239 print(
"[FATAL] --scheduler requires --cluster in this version.", file=sys.stderr)
10244 if getattr(args,
'restart_from',
None):
10245 print(
"[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
10246 if getattr(args,
'continue_run',
False)
and not args.post_process:
10247 print(
"[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
10253 'case':
read_yaml_file(args.case),
'case_path': os.path.abspath(args.case),
10254 'solver':
read_yaml_file(args.solver),
'solver_path': os.path.abspath(args.solver),
10255 'monitor':
read_yaml_file(args.monitor),
'monitor_path': os.path.abspath(args.monitor),
10256 'walltime_guard_policy': walltime_guard_policy,
10259 print(
"\n[INFO] Validating configuration files...")
10261 configs[
'case'], configs[
'solver'], configs[
'monitor'],
10262 args.case, args.solver, args.monitor
10264 print(
"[SUCCESS] All configuration files passed validation.\n")
10266 continue_mode = getattr(args,
'continue_run',
False)
10269 if not args.run_dir:
10271 run_dir = os.path.abspath(args.run_dir)
10272 if not os.path.isdir(run_dir):
10274 ERROR_CODE_CFG_FILE_NOT_FOUND,
10277 message=
"Specified run directory not found.",
10280 run_id = os.path.basename(run_dir)
10282 case_name = os.path.splitext(os.path.basename(args.case))[0]
10283 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
10284 run_id = f
"{case_name}_{timestamp}"
10285 run_dir = os.path.abspath(os.path.join(
"runs", run_id))
10289 args, configs[
"case"], configs[
"solver"], configs[
"monitor"], run_dir
10291 except ValueError
as e:
10293 ERROR_CODE_CFG_INCONSISTENT_COMBO,
10295 file_path=args.case,
10300 config_dir = os.path.join(run_dir,
"config")
10301 if not continue_mode:
10302 for d
in [config_dir, os.path.join(run_dir,
"scheduler")]:
10303 os.makedirs(d, exist_ok=
True)
10305 os.makedirs(config_dir, exist_ok=
True)
10307 print(f
"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
10309 print(f
"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
10311 shutil.copy(args.case, os.path.join(config_dir,
"case.yml"))
10312 shutil.copy(args.solver, os.path.join(config_dir,
"solver.yml"))
10313 shutil.copy(args.monitor, os.path.join(config_dir,
"monitor.yml"))
10315 shutil.copy(cluster_path, os.path.join(config_dir,
"cluster.yml"))
10317 print(
"\n" +
"="*25 +
" SOLVER STAGE " +
"="*25)
10318 source_files = {
'Case': args.case,
'Solver': args.solver,
'Monitor': args.monitor}
10320 if resolved_restart_source_dir:
10321 print(f
"[INFO] Restart source: {resolved_restart_source_dir}")
10323 print(
"[INFO] Continue mode: logs will be appended, not overwritten.")
10328 solver_num_procs_effective,
10330 restart_source_dir=resolved_restart_source_dir,
10331 continue_mode=is_continue,
10337 scheduler_dir = os.path.join(run_dir,
"scheduler")
10338 solver_script = os.path.join(scheduler_dir,
"solver.sbatch")
10339 solver_log = os.path.join(scheduler_dir,
"solver_%j.out")
10340 solver_err = os.path.join(scheduler_dir,
"solver_%j.err")
10345 config_search_anchor=args.case,
10346 extra_search_anchors=[cluster_path],
10356 env_vars={
"LOG_LEVEL": configs[
'monitor'].get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
10359 submission_meta[
"stages"][
"solve"] = {
10360 "script": solver_script,
10361 "submitted":
False,
10362 "num_procs_effective": solver_num_procs_effective,
10364 print(f
"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
10365 if not args.no_submit:
10367 submission_meta[
"stages"][
"solve"].update(submit_info)
10368 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
10369 print(f
"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
10370 stages_completed.append(
'solve')
10375 solver_num_procs_effective,
10376 config_search_anchor=configs[
"case_path"],
10378 solver_log = os.path.join(
"scheduler", f
"{run_id}_solver.log")
10379 submission_meta[
"stages"][
"solve"] = {
10380 "command": command,
10382 "log_file": solver_log,
10383 "submitted":
False,
10384 "num_procs_effective": solver_num_procs_effective,
10387 print(f
"[SUCCESS] Staged local solver command: {solver_log}")
10390 submission_meta[
"stages"][
"solve"][
"submitted"] =
True
10391 submission_meta[
"stages"][
"solve"][
"executed"] =
True
10392 submission_meta[
"stages"][
"solve"][
"completed_at"] = datetime.now().isoformat()
10393 stages_completed.append(
'solve')
10396 if args.post_process:
10398 run_dir = os.path.abspath(args.run_dir)
10399 if not os.path.isdir(run_dir):
10400 print(f
"[FATAL] Specified run directory not found: {run_dir}", file=sys.stderr)
10402 print(f
"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
10403 run_id = os.path.basename(run_dir)
10404 elif not args.solve:
10405 print(
"[FATAL] --post-process requires --run-dir when not used with --solve.", file=sys.stderr)
10408 print(
"\n" +
"="*20 +
" POST-PROCESSING STAGE " +
"="*20)
10409 config_dir = os.path.join(run_dir,
"config")
10412 if not all([case_path, monitor_path, solver_control_path]):
10413 print(f
"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
10415 print(
" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
10416 if not monitor_path:
10417 print(
" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
10418 if not solver_control_path:
10419 print(
" - No '.control' file found.", file=sys.stderr)
10422 print(f
"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
10423 print(f
"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
10429 print(
"[INFO] Validating post-processing configuration...")
10431 print(
"[SUCCESS] Post-processing configuration passed validation.\n")
10433 solver_sources_deferred = bool(args.solve
and (cluster_mode
or args.no_submit))
10434 allow_source_frontier_scan =
not solver_sources_deferred
10441 continue_requested=getattr(args,
'continue_run',
False),
10442 allow_source_frontier_scan=allow_source_frontier_scan,
10446 if source_template ==
'<solver_output_dir>':
10447 print(f
"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
10449 print(f
"[INFO] Post-processor source data (user-defined): {os.path.relpath(post_plan['source_data_directory'])}")
10451 if getattr(args,
'continue_run',
False):
10452 if post_plan[
'resume_recipe_match']:
10453 print(f
"[INFO] Post resume recipe match: yes ({post_plan['resume_match_source']}).")
10455 print(
"[INFO] Post resume recipe match: no. Using the configured start_step for this recipe.")
10456 if post_plan[
'completed_frontier_step']
is not None:
10457 print(f
"[INFO] Completed post frontier: step {post_plan['completed_frontier_step']}")
10459 print(
"[INFO] Completed post frontier: none")
10460 if post_plan[
'source_frontier_deferred']:
10461 print(
"[INFO] Source availability frontier: deferred because the solver stage will populate the requested window before post starts.")
10462 elif post_plan[
'source_frontier_step']
is not None:
10463 print(f
"[INFO] Current source availability frontier: step {post_plan['source_frontier_step']}")
10465 print(
"[INFO] Current source availability frontier: none")
10469 if post_plan[
'skip_reason'] ==
'already-complete-window':
10470 print(
"[INFO] Requested post window is already complete; skipping postprocessor launch.")
10472 elif post_plan[
'skip_reason'] ==
'already-caught-up-to-current-source-frontier':
10473 print(
"[INFO] Post outputs are already caught up to the current fully available source frontier; nothing new to launch right now.")
10474 diagnostic = post_plan.get(
'source_frontier_diagnostic')
or {}
10475 first_incomplete = diagnostic.get(
'first_incomplete_step')
10476 if first_incomplete
is not None:
10477 print(f
"[INFO] First incomplete requested source step: {first_incomplete}")
10479 "[INFO] Closest complete source steps: "
10480 f
"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10481 f
"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10483 elif post_plan[
'skip_reason'] ==
'nothing-available-yet':
10484 diagnostic = post_plan.get(
'source_frontier_diagnostic')
or {}
10485 first_incomplete = diagnostic.get(
'first_incomplete_step')
10486 if first_incomplete
is not None:
10488 f
"[INFO] First requested source step {first_incomplete} is incomplete; "
10489 "skipping postprocessor launch for now."
10492 "[INFO] Closest complete source steps: "
10493 f
"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10494 f
"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10496 missing_files = diagnostic.get(
'missing_files_for_first_incomplete_step')
or []
10498 print(f
"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
10500 print(
"[INFO] No fully available source steps exist yet in the requested window; skipping postprocessor launch for now.")
10503 f
"[INFO] Effective post window: {post_plan['effective_start_step']}..{post_plan['effective_end_step']} "
10504 f
"(stride {post_plan['step_interval']})"
10507 post_effective_cfg = post_plan[
'effective_post_cfg']
10508 post_io_cfg = post_effective_cfg.get(
'io', {})
10510 output_dir_rel = post_io_cfg[
'output_directory']
10511 output_prefix = post_io_cfg[
'output_filename_prefix']
10512 except KeyError
as e:
10513 print(f
"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
10516 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
10517 os.makedirs(output_dir_abs, exist_ok=
True)
10518 print(f
"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
10520 for stats_path
in statistics_output_paths:
10521 print(f
"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
10523 source_files_post = {
'Case': case_path,
'Post-Profile': args.post}
10529 solver_control_path,
10530 "-postprocessing_config_file",
10534 scheduler_dir = os.path.join(run_dir,
"scheduler")
10535 os.makedirs(scheduler_dir, exist_ok=
True)
10536 post_script = os.path.join(scheduler_dir,
"post.sbatch")
10537 post_log = os.path.join(scheduler_dir,
"post_%j.out")
10538 post_err = os.path.join(scheduler_dir,
"post_%j.err")
10544 config_search_anchor=case_path,
10545 extra_search_anchors=[cluster_path],
10546 force_num_procs=post_num_procs_effective,
10550 post_plan[
'recipe_fingerprint'],
10552 create_wrapper=
True,
10562 env_vars={
"LOG_LEVEL": monitor_cfg.get(
'logging', {}).get(
'verbosity',
'INFO').upper()},
10564 submission_meta[
"stages"][
"post-process"] = {
10565 "script": post_script,
10566 "submitted":
False,
10567 "num_procs_effective": post_num_procs_effective,
10568 "resume_recipe_match": post_plan[
'resume_recipe_match'],
10569 "resume_bootstrapped": post_plan[
'resume_bootstrapped'],
10570 "resume_match_source": post_plan[
'resume_match_source'],
10571 "effective_start_step": post_plan[
'effective_start_step'],
10572 "effective_end_step": post_plan[
'effective_end_step'],
10573 "completed_frontier_step": post_plan[
'completed_frontier_step'],
10574 "source_frontier_step": post_plan[
'source_frontier_step'],
10575 "source_frontier_deferred": post_plan[
'source_frontier_deferred'],
10576 "recipe_fingerprint": post_plan[
'recipe_fingerprint'],
10578 print(f
"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
10580 if not args.no_submit:
10581 dependency_job =
None
10583 dependency_job = submission_meta.get(
"stages", {}).get(
"solve", {}).get(
"job_id")
10584 submit_info =
submit_sbatch(post_script, dependency=dependency_job)
10585 submission_meta[
"stages"][
"post-process"].update(submit_info)
10586 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
10588 submission_meta[
"stages"][
"post-process"][
"dependency"] = f
"afterok:{dependency_job}"
10589 print(f
"[SUCCESS] Submitted post job: {submit_info['job_id']}")
10590 stages_completed.append(
'post-process')
10595 post_num_procs_effective,
10596 config_search_anchor=case_path,
10597 allow_single_rank_launcher_override=
True,
10598 force_num_procs=post_num_procs_effective,
10602 post_plan[
'recipe_fingerprint'],
10604 create_wrapper=
True,
10606 post_log = os.path.join(
"scheduler", f
"{run_id}_{output_prefix}.log")
10607 submission_meta[
"stages"][
"post-process"] = {
10608 "command": command,
10610 "log_file": post_log,
10611 "submitted":
False,
10612 "num_procs_effective": post_num_procs_effective,
10613 "resume_recipe_match": post_plan[
'resume_recipe_match'],
10614 "resume_bootstrapped": post_plan[
'resume_bootstrapped'],
10615 "resume_match_source": post_plan[
'resume_match_source'],
10616 "effective_start_step": post_plan[
'effective_start_step'],
10617 "effective_end_step": post_plan[
'effective_end_step'],
10618 "completed_frontier_step": post_plan[
'completed_frontier_step'],
10619 "source_frontier_step": post_plan[
'source_frontier_step'],
10620 "source_frontier_deferred": post_plan[
'source_frontier_deferred'],
10621 "recipe_fingerprint": post_plan[
'recipe_fingerprint'],
10624 print(f
"[SUCCESS] Staged local post command: {post_log}")
10628 submission_meta[
"stages"][
"post-process"][
"submitted"] =
True
10629 submission_meta[
"stages"][
"post-process"][
"executed"] =
True
10630 submission_meta[
"stages"][
"post-process"][
"completed_at"] = datetime.now().isoformat()
10631 stages_completed.append(
'post-process')
10636 "created_at": datetime.now().isoformat(),
10637 "launch_mode":
"slurm" if cluster_mode
else "local",
10639 "num_procs": solver_num_procs_effective,
10640 "solver_num_procs": solver_num_procs_effective,
10641 "post_num_procs": post_num_procs_effective,
10642 "stages_requested": {
"solve": bool(args.solve),
"post_process": bool(args.post_process)},
10643 "stages_completed_or_submitted": stages_completed,
10647 manifest[
"inputs"][
"case"] = os.path.abspath(args.case)
10648 manifest[
"inputs"][
"solver"] = os.path.abspath(args.solver)
10649 manifest[
"inputs"][
"monitor"] = os.path.abspath(args.monitor)
10650 if args.post_process:
10651 manifest[
"inputs"][
"post"] = os.path.abspath(args.post)
10653 manifest[
"inputs"][
"cluster"] = cluster_path
10654 if submission_meta.get(
"stages"):
10655 write_json_file(os.path.join(run_dir,
"scheduler",
"submission.json"), submission_meta)
10658 if stages_completed:
10659 elapsed = time.time() - workflow_start
10660 mins, secs = divmod(int(elapsed), 60)
10661 hrs, mins = divmod(mins, 60)
10663 time_str = f
"{hrs}h {mins}m {secs}s"
10665 time_str = f
"{mins}m {secs}s"
10667 time_str = f
"{secs}s"
10669 print(
"\n" +
"=" * 60)
10670 print(
" RUN SUMMARY")
10672 print(f
" Run ID : {run_id}")
10673 print(f
" Run directory : {os.path.relpath(run_dir)}")
10674 print(f
" Wall-clock : {time_str}")
10675 print(f
" Stages : {', '.join(stages_completed)}")
10676 print(f
" Launch mode : {'slurm' if cluster_mode else 'local'}")
10678 print(f
" Solver MPI procs: {solver_num_procs_effective}")
10679 if args.post_process:
10680 print(f
" Post MPI procs : {post_num_procs_effective}")
10681 if args.solve
and configs:
10682 total_steps = configs[
'case'].get(
'run_control', {}).get(
'total_steps',
'?')
10683 result_dir = os.path.join(run_dir, configs[
'monitor'].get(
'io', {}).get(
'directories', {}).get(
'output',
'output'))
10684 print(f
" Steps run : {total_steps}")
10685 print(f
" Solver output : {os.path.relpath(result_dir)}")
10686 if 'post-process' in stages_completed
and output_dir_abs:
10687 print(f
" Post output : {os.path.relpath(output_dir_abs)}")
10688 for stats_path
in statistics_output_paths:
10689 print(f
" Stats output : {os.path.relpath(stats_path)}")
10690 print(f
" Logs : {os.path.relpath(os.path.join(run_dir, 'logs'))}")
10691 if cluster_mode
or submission_meta.get(
"stages"):
10692 submission_file = os.path.join(run_dir,
"scheduler",
"submission.json")
10693 print(f
" Submission meta: {os.path.relpath(submission_file)}")
10699 @brief Parse a case_index.tsv file back into a list of case entry dicts.
10700 @param[in] tsv_path Path to the case_index.tsv file.
10701 @return List of dicts with keys: index, case_id, run_dir, control_file,
10702 post_recipe_file, log_level, post_prefix.
10705 with open(tsv_path)
as f:
10707 line = line.strip()
10710 parts = line.split(
"\t")
10712 "index": int(parts[0]),
10713 "case_id": parts[1],
10714 "run_dir": parts[2],
10715 "control_file": parts[3],
10716 "post_recipe_file": parts[4],
10717 "log_level": parts[5],
10718 "post_prefix": parts[6],
10725 @brief Study/sweep orchestration using Slurm job arrays.
10726 @param[in] args Command-line style argument list supplied to the function.
10728 study_path = os.path.abspath(args.study)
10729 cluster_path = os.path.abspath(args.cluster)
10736 study_name = os.path.splitext(os.path.basename(study_path))[0]
10737 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
10738 study_id = f
"{study_name}_{timestamp}"
10739 study_dir = os.path.abspath(os.path.join(
"studies", study_id))
10740 cases_dir = os.path.join(study_dir,
"cases")
10741 scheduler_dir = os.path.join(study_dir,
"scheduler")
10742 results_dir = os.path.join(study_dir,
"results")
10743 for path
in [cases_dir, scheduler_dir, results_dir]:
10744 os.makedirs(path, exist_ok=
True)
10746 print(f
"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
10747 shutil.copy(study_path, os.path.join(study_dir,
"study.yml"))
10748 shutil.copy(cluster_path, os.path.join(study_dir,
"cluster.yml"))
10750 base_cfgs = study_cfg[
"base_configs"]
10751 base_paths = {k:
resolve_path(study_path, v)
for k, v
in base_cfgs.items()}
10756 validate_solver_configs(base_case, base_solver, base_monitor, base_paths[
"case"], base_paths[
"solver"], base_paths[
"monitor"])
10760 if not combinations:
10761 print(
"[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
10763 print(f
"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
10767 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
10769 for idx, combo
in enumerate(combinations):
10770 case_id = f
"case_{idx:04d}"
10771 run_dir = os.path.join(cases_dir, case_id)
10772 config_dir = os.path.join(run_dir,
"config")
10773 os.makedirs(config_dir, exist_ok=
True)
10774 os.makedirs(os.path.join(run_dir,
"logs"), exist_ok=
True)
10775 os.makedirs(os.path.join(run_dir,
"output"), exist_ok=
True)
10777 case_cfg = copy.deepcopy(base_case)
10778 solver_cfg = copy.deepcopy(base_solver)
10779 monitor_cfg = copy.deepcopy(base_monitor)
10780 post_cfg = copy.deepcopy(base_post)
10781 target_map = {
"case": case_cfg,
"solver": solver_cfg,
"monitor": monitor_cfg,
"post": post_cfg}
10782 for full_key, value
in combo.items():
10783 root, nested = full_key.split(
".", 1)
10784 _deep_set(target_map[root], nested, value)
10790 case_path = os.path.join(config_dir,
"case.yml")
10791 solver_path = os.path.join(config_dir,
"solver.yml")
10792 monitor_path = os.path.join(config_dir,
"monitor.yml")
10793 post_path = os.path.join(config_dir,
"post.yml")
10802 source_files = {
'Case': case_path,
'Solver': solver_path,
'Monitor': monitor_path}
10805 "case": case_cfg,
"case_path": case_path,
10806 "solver": solver_cfg,
"solver_path": solver_path,
10807 "monitor": monitor_cfg,
"monitor_path": monitor_path,
10813 if not isinstance(post_cfg.get(
'source_data'), dict):
10814 post_cfg[
'source_data'] = {}
10815 post_cfg[
'source_data'][
'directory'] = source_dir
10816 output_prefix = post_cfg.get(
"io", {}).get(
"output_filename_prefix",
"post")
10817 post_recipe =
generate_post_recipe_file(run_dir, case_id, post_cfg, {
'Case': case_path,
'Post-Profile': post_path}, monitor_cfg)
10819 case_entries.append({
10821 "case_id": case_id,
10822 "run_dir": os.path.abspath(run_dir),
10823 "control_file": control_file,
10824 "post_recipe_file": post_recipe,
10825 "log_level": str(monitor_cfg.get(
"logging", {}).get(
"verbosity",
"INFO")).upper(),
10826 "post_prefix": output_prefix,
10829 "parameters": combo,
10832 with open(case_index_file,
"w")
as f:
10833 for entry
in case_entries:
10837 str(entry[
"index"]),
10840 entry[
"control_file"],
10841 entry[
"post_recipe_file"],
10842 entry[
"log_level"],
10843 entry[
"post_prefix"],
10844 entry[
"solve_diagnostic_args"],
10845 entry[
"post_diagnostic_args"],
10849 print(f
"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
10851 max_idx = len(case_entries) - 1
10852 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
10853 array_spec = f
"0-{max_idx}"
10855 array_spec = f
"{array_spec}%{max_conc}"
10859 solver_array_script = os.path.join(scheduler_dir,
"solver_array.sbatch")
10860 post_array_script = os.path.join(scheduler_dir,
"post_array.sbatch")
10862 solver_array_script,
10863 f
"{study_id}_solve",
10870 os.path.join(scheduler_dir,
"solver_%A_%a.out"),
10871 os.path.join(scheduler_dir,
"solver_%A_%a.err")
10875 f
"{study_id}_post",
10882 os.path.join(scheduler_dir,
"post_%A_%a.out"),
10883 os.path.join(scheduler_dir,
"post_%A_%a.err")
10885 print(f
"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
10887 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
10888 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_aggregate.sbatch")
10890 metrics_aggregate_script,
10891 f
"{study_id}_metrics",
10896 print(f
"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
10899 "launch_mode":
"slurm",
10900 "study_id": study_id,
10901 "solver_array": {
"script": solver_array_script,
"submitted":
False},
10902 "post_array": {
"script": post_array_script,
"submitted":
False},
10903 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
10904 "no_submit": bool(args.no_submit),
10906 if not args.no_submit:
10908 submission[
"solver_array"].update(solver_submit)
10909 submission[
"solver_array"][
"submitted"] =
True
10910 post_submit =
submit_sbatch(post_array_script, dependency=solver_submit[
"job_id"])
10911 submission[
"post_array"].update(post_submit)
10912 submission[
"post_array"][
"submitted"] =
True
10913 submission[
"post_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
10914 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
10915 submission[
"metrics_aggregate"].update(metrics_submit)
10916 submission[
"metrics_aggregate"][
"submitted"] =
True
10917 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
10918 print(f
"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
10919 print(f
"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
10920 print(f
"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
10926 "study_id": study_id,
10927 "created_at": datetime.now().isoformat(),
10929 "study_type": study_cfg.get(
"study_type"),
10930 "num_cases": len(case_entries),
10932 "study_dir": study_dir,
10933 "case_index": case_index_file,
10934 "solver_array_script": solver_array_script,
10935 "post_array_script": post_array_script,
10936 "metrics_table": metrics_csv,
10937 "plots_dir": os.path.join(results_dir,
"plots"),
10939 "submission": submission,
10941 write_json_file(os.path.join(scheduler_dir,
"submission.json"), submission)
10942 write_json_file(os.path.join(study_dir,
"study_manifest.json"), summary)
10943 write_json_file(os.path.join(results_dir,
"summary.json"), {
"study_id": study_id,
"metrics_csv": metrics_csv,
"plots": plots})
10945 print(
"\n" +
"=" * 60)
10946 print(
" STUDY SUMMARY")
10948 print(f
" Study ID : {study_id}")
10949 print(f
" Study directory : {os.path.relpath(study_dir)}")
10950 print(f
" Cases generated : {len(case_entries)}")
10951 print(f
" Array spec : {array_spec}")
10952 print(f
" Solver script : {os.path.relpath(solver_array_script)}")
10953 print(f
" Post script : {os.path.relpath(post_array_script)}")
10955 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
10957 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
10963 @brief Continue a partially-completed Slurm parameter sweep study.
10964 @details Detects incomplete cases, prepares them for continuation (updating
10965 start_step, populating restart directories, regenerating control files),
10966 and submits new solver/post/metrics Slurm jobs. If all cases are already
10967 complete, performs metrics aggregation automatically.
10968 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
10970 study_dir = os.path.abspath(args.study_dir)
10971 manifest_path = os.path.join(study_dir,
"study_manifest.json")
10972 if not os.path.isfile(manifest_path):
10973 print(f
"[FATAL] Study manifest not found: {manifest_path}", file=sys.stderr)
10976 study_id = manifest[
"study_id"]
10978 study_path = os.path.join(study_dir,
"study.yml")
10979 cluster_path = os.path.abspath(args.cluster)
if args.cluster
else os.path.join(study_dir,
"cluster.yml")
10986 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir,
"cluster.yml"))
10987 print(f
"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
10989 scheduler_dir = os.path.join(study_dir,
"scheduler")
10990 cases_dir = os.path.join(study_dir,
"cases")
10991 results_dir = os.path.join(study_dir,
"results")
10992 case_index_file = os.path.join(scheduler_dir,
"case_index.tsv")
10993 if not os.path.isfile(case_index_file):
10994 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
10999 base_cfgs = study_cfg[
"base_configs"]
11000 base_paths = {k:
resolve_path(study_path, v)
for k, v
in base_cfgs.items()}
11004 if len(combinations) != len(parsed_entries):
11006 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11007 f
"case_index.tsv ({len(parsed_entries)} entries).",
11012 print(f
"\n[INFO] Study: {study_id}")
11013 print(f
"[INFO] Scanning {len(combinations)} case(s) for completion status...")
11015 incomplete_indices = []
11016 all_case_entries = []
11017 for idx, combo
in enumerate(combinations):
11018 case_id = f
"case_{idx:04d}"
11019 entry = parsed_entries[idx]
11020 entry[
"parameters"] = combo
11021 run_dir = entry[
"run_dir"]
11023 effective_case = copy.deepcopy(base_case)
11024 for full_key, value
in combo.items():
11025 root, nested = full_key.split(
".", 1)
11027 _deep_set(effective_case, nested, value)
11029 eff_start = int(effective_case.get(
"run_control", {}).get(
"start_step", 0)
or 0)
11030 except (TypeError, ValueError):
11032 eff_total = int(effective_case[
"run_control"][
"total_steps"])
11033 target = eff_start + eff_total
11035 monitor_cfg =
read_yaml_file(os.path.join(run_dir,
"config",
"monitor.yml"))
11037 entry[
"_status"] = status
11039 if status[
"status"] ==
"complete":
11040 print(f
" {case_id}: complete (step {status['last_step']}/{target})")
11041 elif status[
"status"] ==
"partial":
11042 print(f
" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
11043 incomplete_indices.append(idx)
11045 print(f
" {case_id}: no checkpoint — will re-run from scratch")
11046 incomplete_indices.append(idx)
11048 all_case_entries.append(entry)
11050 if not incomplete_indices:
11051 print(
"\n[INFO] All cases are complete. Running metrics aggregation...")
11054 print(
"\n" +
"=" * 60)
11055 print(
" STUDY CONTINUATION SUMMARY")
11057 print(f
" Study ID : {study_id}")
11058 print(f
" Status : ALL COMPLETE")
11060 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
11062 print(f
" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
11066 print(f
"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
11069 for idx
in incomplete_indices:
11070 entry = all_case_entries[idx]
11071 status = entry[
"_status"]
11072 if status[
"status"] ==
"partial":
11074 entry[
"run_dir"], entry[
"case_id"],
11075 status[
"last_step"], status[
"target_step"],
11078 elif status[
"status"] ==
"empty":
11079 print(f
"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
11081 solver_array_spec =
",".join(str(i)
for i
in incomplete_indices)
11082 max_conc = study_cfg.get(
"execution", {}).get(
"max_concurrent_array_tasks")
11084 solver_array_spec = f
"{solver_array_spec}%{max_conc}"
11086 max_idx = len(combinations) - 1
11087 post_array_spec = f
"0-{max_idx}"
11089 post_array_spec = f
"{post_array_spec}%{max_conc}"
11094 solver_continue_script = os.path.join(scheduler_dir,
"solver_continue_array.sbatch")
11095 post_continue_script = os.path.join(scheduler_dir,
"post_continue_array.sbatch")
11097 solver_continue_script,
11098 f
"{study_id}_solve_cont",
11103 solver_exe, post_exe,
11104 os.path.join(scheduler_dir,
"solver_cont_%A_%a.out"),
11105 os.path.join(scheduler_dir,
"solver_cont_%A_%a.err"),
11108 post_continue_script,
11109 f
"{study_id}_post_cont",
11114 solver_exe, post_exe,
11115 os.path.join(scheduler_dir,
"post_cont_%A_%a.out"),
11116 os.path.join(scheduler_dir,
"post_cont_%A_%a.err"),
11119 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR,
"picurv"))
11120 metrics_aggregate_script = os.path.join(scheduler_dir,
"metrics_continue_aggregate.sbatch")
11122 metrics_aggregate_script,
11123 f
"{study_id}_metrics_cont",
11128 print(f
"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
11131 "launch_mode":
"slurm",
11132 "study_id": study_id,
11133 "continuation":
True,
11134 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
11135 "solver_continue_array": {
"script": solver_continue_script,
"submitted":
False},
11136 "post_continue_array": {
"script": post_continue_script,
"submitted":
False},
11137 "metrics_aggregate": {
"script": metrics_aggregate_script,
"submitted":
False},
11138 "no_submit": bool(args.no_submit),
11140 if not args.no_submit:
11142 submission[
"solver_continue_array"].update(solver_submit)
11143 submission[
"solver_continue_array"][
"submitted"] =
True
11144 post_submit =
submit_sbatch(post_continue_script, dependency=solver_submit[
"job_id"])
11145 submission[
"post_continue_array"].update(post_submit)
11146 submission[
"post_continue_array"][
"submitted"] =
True
11147 submission[
"post_continue_array"][
"dependency"] = f
"afterok:{solver_submit['job_id']}"
11148 metrics_submit =
submit_sbatch(metrics_aggregate_script, dependency=post_submit[
"job_id"], dependency_type=
"afterany")
11149 submission[
"metrics_aggregate"].update(metrics_submit)
11150 submission[
"metrics_aggregate"][
"submitted"] =
True
11151 submission[
"metrics_aggregate"][
"dependency"] = f
"afterany:{post_submit['job_id']}"
11152 print(f
"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
11153 print(f
"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
11154 print(f
"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
11156 write_json_file(os.path.join(scheduler_dir,
"submission_continue.json"), submission)
11158 manifest[
"continuation"] = {
11159 "continued_at": datetime.now().isoformat(),
11160 "incomplete_cases": [all_case_entries[i][
"case_id"]
for i
in incomplete_indices],
11161 "submission": submission,
11165 print(
"\n" +
"=" * 60)
11166 print(
" STUDY CONTINUATION SUMMARY")
11168 print(f
" Study ID : {study_id}")
11169 print(f
" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
11170 print(f
" Solver array spec : {solver_array_spec}")
11171 print(f
" Post array spec : {post_array_spec}")
11172 print(f
" Solver script : {os.path.relpath(solver_continue_script)}")
11173 print(f
" Post script : {os.path.relpath(post_continue_script)}")
11174 print(f
" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
11175 if not args.no_submit:
11176 print(f
" [Metrics aggregation will run automatically after post-processing]")
11178 print(f
" [--no-submit] Scripts generated but not submitted.")
11179 print(f
" After manual submission and completion, run:")
11180 print(f
" picurv sweep --reaggregate --study-dir {os.path.relpath(study_dir)}")
11186 @brief Re-run metrics aggregation and plot generation for an existing study.
11187 @param[in] args Parsed CLI arguments with study_dir.
11189 study_dir = os.path.abspath(args.study_dir)
11190 study_path = os.path.join(study_dir,
"study.yml")
11191 if not os.path.isfile(study_path):
11192 print(f
"[FATAL] Study config not found: {study_path}", file=sys.stderr)
11197 case_index_file = os.path.join(study_dir,
"scheduler",
"case_index.tsv")
11198 if not os.path.isfile(case_index_file):
11199 print(f
"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
11204 if len(combinations) != len(parsed_entries):
11206 f
"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11207 f
"case_index.tsv ({len(parsed_entries)} entries).",
11213 for idx, combo
in enumerate(combinations):
11214 entry = parsed_entries[idx]
11215 entry[
"parameters"] = combo
11216 case_entries.append(entry)
11218 results_dir = os.path.join(study_dir,
"results")
11222 print(
"\n" +
"=" * 60)
11223 print(
" REAGGREGATION SUMMARY")
11226 print(f
" Metrics table : {os.path.relpath(metrics_csv)}")
11228 print(f
" Plots generated : {len(plots)}")
11232_SUMMARY_NUMERIC_RE = re.compile(
r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
11237 @brief Read YAML when present, otherwise return None.
11238 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
11239 @return Value returned by `_read_yaml_if_exists()`.
11241 if not filepath
or not os.path.isfile(filepath):
11244 with open(filepath,
"r", encoding=
"utf-8")
as f:
11245 return yaml.safe_load(f)
11246 except yaml.YAMLError:
11252 @brief Read JSON when present, otherwise return None.
11253 @param[in] filepath Argument passed to `_read_json_if_exists()`.
11254 @return Value returned by `_read_json_if_exists()`.
11256 if not filepath
or not os.path.isfile(filepath):
11258 with open(filepath,
"r", encoding=
"utf-8")
as f:
11259 return json.load(f)
11264 @brief Best-effort integer parsing for summary extraction.
11265 @param[in] value Argument passed to `_parse_int_loose()`.
11266 @return Value returned by `_parse_int_loose()`.
11270 text = str(value).strip()
11277 return int(float(text))
11284 @brief Best-effort float parsing for summary extraction.
11285 @param[in] value Argument passed to `_parse_float_loose()`.
11286 @return Value returned by `_parse_float_loose()`.
11290 text = str(value).strip()
11301 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
11302 @param[in] text Argument passed to `_extract_numeric_tuple()`.
11303 @return Value returned by `_extract_numeric_tuple()`.
11307 return [float(token)
for token
in _SUMMARY_NUMERIC_RE.findall(text)]
11312 @brief Resolve run-local config and artifact paths for summarize.
11313 @param[in] run_dir Argument passed to `_build_summary_context()`.
11314 @return Value returned by `_build_summary_context()`.
11316 run_dir = os.path.abspath(run_dir)
11317 if not os.path.isdir(run_dir):
11319 ERROR_CODE_CFG_FILE_NOT_FOUND,
11322 message=
"Run directory not found.",
11326 config_dir = os.path.join(run_dir,
"config")
11328 "case": os.path.join(config_dir,
"case.yml"),
11329 "solver": os.path.join(config_dir,
"solver.yml"),
11330 "monitor": os.path.join(config_dir,
"monitor.yml"),
11337 io_cfg = monitor_cfg.get(
"io", {})
if isinstance(monitor_cfg, dict)
else {}
11338 io_dirs = io_cfg.get(
"directories", {})
if isinstance(io_cfg, dict)
else {}
11339 log_dir_name = io_dirs.get(
"log",
"logs")
11340 scheduler_dir = os.path.join(run_dir,
"scheduler")
11342 profiling_cfg = {
"mode":
"off",
"functions": [],
"timestep_file":
"Profiling_Timestep_Summary.csv",
"final_summary_enabled":
True}
11346 particle_console_output_freq =
None
11347 particle_log_interval =
None
11350 particle_log_interval = io_cfg.get(
"particle_log_interval")
11352 particle_count_cfg =
None
11354 particle_count_cfg = (
11355 case_cfg.get(
"models", {})
11356 .get(
"physics", {})
11357 .get(
"particles", {})
11362 "run_dir": run_dir,
11363 "config_dir": config_dir,
11364 "log_dir": os.path.join(run_dir, log_dir_name),
11365 "scheduler_dir": scheduler_dir,
11366 "monitor_cfg": monitor_cfg,
11367 "case_cfg": case_cfg,
11368 "solver_cfg": solver_cfg,
11369 "config_paths": config_paths,
11370 "manifest": manifest,
11371 "profiling_cfg": profiling_cfg,
11372 "particle_console_output_freq": particle_console_output_freq,
11373 "particle_log_interval": particle_log_interval,
11374 "particle_count_cfg": particle_count_cfg,
11380 @brief Return one explicitly requested copied config or fail with a structured error.
11381 @param[in] context Summary context returned by `_build_summary_context()`.
11382 @param[in] name Config selector name.
11383 @return Parsed config mapping.
11385 path = context[
"config_paths"][name]
11386 cfg = context.get(f
"{name}_cfg")
11387 if not os.path.isfile(path):
11389 ERROR_CODE_CFG_FILE_NOT_FOUND,
11392 message=f
"Copied run config '{name}.yml' was not found.",
11393 hint=
"Use a staged run directory containing the requested copied config.",
11396 if not isinstance(cfg, dict)
or not cfg:
11398 ERROR_CODE_CFG_INVALID_VALUE,
11401 message=f
"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
11409 @brief Build timestep-independent run metadata for summarize.
11410 @param[in] context Summary context returned by `_build_summary_context()`.
11411 @return Curated run metadata mapping.
11413 manifest = context[
"manifest"]
11415 "run_id": manifest.get(
"run_id", os.path.basename(context[
"run_dir"])),
11416 "run_dir": context[
"run_dir"],
11417 "created_at": manifest.get(
"created_at"),
11418 "launch_mode": manifest.get(
"launch_mode"),
11419 "git_commit": manifest.get(
"git_commit"),
11420 "solver_num_procs": manifest.get(
"solver_num_procs", manifest.get(
"num_procs")),
11421 "post_num_procs": manifest.get(
"post_num_procs"),
11422 "stages_requested": manifest.get(
"stages_requested"),
11423 "stages_completed_or_submitted": manifest.get(
"stages_completed_or_submitted"),
11429 @brief Build compact turbulence and wall-model selections.
11430 @param[in] turbulence_cfg Case turbulence configuration mapping.
11431 @return Curated turbulence and wall-model mapping.
11434 for key
in (
"les",
"rans",
"wall_function"):
11435 value = turbulence_cfg.get(key)
11436 if isinstance(value, dict):
11438 "enabled": value.get(
"enabled",
True),
11439 "model": value.get(
"model"),
11440 **{k: v
for k, v
in value.items()
if k
not in {
"enabled",
"model"}},
11442 elif value
is not None:
11443 result[key] = value
11449 @brief Build a curated case.yml summary with useful derived quantities.
11450 @param[in] context Summary context returned by `_build_summary_context()`.
11451 @return Curated case configuration mapping.
11454 props = cfg.get(
"properties", {})
11455 scaling = props.get(
"scaling", {})
11456 fluid = props.get(
"fluid", {})
11457 run = cfg.get(
"run_control", {})
11458 grid = cfg.get(
"grid", {})
11459 models = cfg.get(
"models", {})
11460 domain = models.get(
"domain", {})
11461 physics = models.get(
"physics", {})
11462 particles = physics.get(
"particles", {})
11463 start = int(run.get(
"start_step", 0))
11464 total = int(run.get(
"total_steps", 0))
11465 dt = float(run.get(
"dt_physical", 0.0))
11466 length_ref = float(scaling.get(
"length_ref"))
11467 velocity_ref = float(scaling.get(
"velocity_ref"))
11468 density = float(fluid.get(
"density"))
11469 viscosity = float(fluid.get(
"viscosity"))
11471 first_block_faces = {row[
"face"]: row
for row
in prepared_bcs[0]}
11473 "i": first_block_faces[
"-Xi"][
"type"] ==
"PERIODIC",
11474 "j": first_block_faces[
"-Eta"][
"type"] ==
"PERIODIC",
11475 "k": first_block_faces[
"-Zeta"][
"type"] ==
"PERIODIC",
11478 for block_idx, block
in enumerate(prepared_bcs):
11481 "block": block_idx,
11483 {
"face": row[
"face"],
"type": row[
"type"],
"handler": row[
"handler"]}
11490 "start_step": start,
11491 "total_steps": total,
11492 "end_step": start + total,
11494 "duration_physical": total * dt,
11495 "dt_nondimensional": dt * velocity_ref / length_ref,
11498 "length_ref": length_ref,
11499 "velocity_ref": velocity_ref,
11500 "density": density,
11501 "viscosity": viscosity,
11502 "reynolds_number": density * velocity_ref * length_ref / viscosity
if viscosity
else None,
11503 "initial_conditions": props.get(
"initial_conditions", {}),
11506 "mode": grid.get(
"mode"),
11508 "programmatic_settings": grid.get(
"programmatic_settings")
if grid.get(
"mode") ==
"programmatic_c" else None,
11509 "source_file": grid.get(
"source_file"),
11512 "blocks": domain.get(
"blocks", 1),
11513 "dimensionality": physics.get(
"dimensionality",
"3D"),
11514 "periodic": periodic_axes,
11517 "fsi": physics.get(
"fsi", {}),
11518 "particles": particles,
11520 "statistics": models.get(
"statistics", {}),
11522 "boundary_conditions": bc_blocks,
11528 @brief Build a curated solver.yml summary with normalized selections.
11529 @param[in] context Summary context returned by `_build_summary_context()`.
11530 @return Curated solver configuration mapping.
11533 strategy = cfg.get(
"strategy", {})
or {}
11535 momentum_cfg = cfg.get(
"momentum_solver", {})
or {}
11536 dualtime = momentum_cfg.get(
"dual_time_picard_jameson_rk", momentum_cfg.get(
"dual_time_picard_rk4", {}))
or {}
11537 poisson = cfg.get(
"poisson_solver", cfg.get(
"pressure_solver", {}))
or {}
11538 convergence = cfg.get(
"solution_convergence", {})
or {}
11540 operation_mode = cfg.get(
"operation_mode", {})
or {}
11545 if operation_mode.get(
"analytical_type")
is not None:
11547 passthrough = cfg.get(
"petsc_passthrough_options", {})
or {}
11549 "operation_mode": operation_mode,
11552 "central_diff": bool(strategy.get(
"central_diff",
False)),
11553 "tolerances": cfg.get(
"tolerances", {}),
11554 "controls": dualtime,
11556 "poisson": poisson,
11557 "interpolation": cfg.get(
"interpolation", {
"method":
"Trilinear"}),
11558 "solution_convergence": {**convergence,
"mode": convergence_mode},
11559 "scalar_transport": cfg.get(
"scalar_transport", {}),
11560 "verification": cfg.get(
"verification", {}),
11561 "petsc_passthrough": {
"count": len(passthrough),
"options": sorted(passthrough.keys())},
11567 @brief Build a curated monitor.yml summary with resolved defaults.
11568 @param[in] context Summary context returned by `_build_summary_context()`.
11569 @return Curated monitor configuration mapping.
11572 logging_cfg = cfg.get(
"logging", {})
or {}
11573 io_cfg = cfg.get(
"io", {})
or {}
11576 enabled_petsc = sorted(key
for key, value
in diagnostics[
"petsc"].items()
if value
not in (
False,
None))
11579 "verbosity": logging_cfg.get(
"verbosity",
"WARNING"),
11580 "enabled_functions": logging_cfg.get(
"enabled_functions", []),
11584 "enabled_petsc": enabled_petsc,
11585 "petsc": diagnostics[
"petsc"],
11586 "runtime_memory_log": diagnostics[
"runtime_memory_log"],
11589 "data_output_frequency": io_cfg.get(
"data_output_frequency"),
11591 "particle_log_interval": io_cfg.get(
"particle_log_interval"),
11592 "directories": io_cfg.get(
"directories", {}),
11594 "solver_monitoring": {
11595 "enabled_flags": sorted(flag
for flag, value
in monitoring_flags.items()
if value
not in (
False,
None)),
11596 "flags": monitoring_flags,
11603 @brief Parse Continuity_Metrics.log into latest rows by step plus observed order.
11604 @param[in] filepath Argument passed to `_parse_continuity_metrics_log()`.
11605 @return Value returned by `_parse_continuity_metrics_log()`.
11610 if not os.path.isfile(filepath):
11611 return rows_by_step, step_order
11613 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11615 line = raw_line.strip()
11616 if not line
or line.startswith(
"-")
or line.startswith(
"Timestep"):
11618 parts = [part.strip()
for part
in raw_line.split(
"|")]
11628 if step
is None or block
is None:
11630 if step != active_step:
11632 step_order.append(step)
11633 rows_by_step[step] = {}
11634 rows_by_step.setdefault(step, {})[block] = {
11636 "max_divergence": max_div,
11637 "max_divergence_location": parts[3],
11638 "rhs_sum": rhs_sum,
11639 "flux_in": flux_in,
11640 "flux_out": flux_out,
11641 "net_flux": net_flux,
11643 return {step:
list(block_rows.values())
for step, block_rows
in rows_by_step.items()}, step_order
11648 @brief Parse Particle_Metrics.log into latest rows by step plus observed order.
11649 @param[in] filepath Argument passed to `_parse_particle_metrics_log()`.
11650 @return Value returned by `_parse_particle_metrics_log()`.
11654 if not os.path.isfile(filepath):
11655 return rows_by_step, step_order
11657 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11659 line = raw_line.strip()
11660 if not line
or line.startswith(
"-")
or line.startswith(
"Stage"):
11662 parts = [part.strip()
for part
in raw_line.split(
"|")]
11672 "lost_particles_cumulative":
None,
11673 "migrated_particles":
None,
11674 "occupied_cells":
None,
11675 "load_imbalance":
None,
11676 "migration_passes":
None,
11678 if len(parts) >= 9:
11697 rows_by_step[step] = row
11698 step_order.append(step)
11699 return rows_by_step, step_order
11704 @brief Parse per-block momentum convergence logs.
11705 @param[in] log_dir Argument passed to `_parse_momentum_convergence_logs()`.
11706 @return Value returned by `_parse_momentum_convergence_logs()`.
11711 pattern = os.path.join(log_dir,
"Momentum_Solver_Convergence_History_Block_*.log")
11712 regex = re.compile(
11713 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\(k\):\s*(?P<pseudo_iter>\d+)\|\s*\|"
11714 r"\s*Pseudo-cfl:\s*(?P<pseudo_cfl>[-+0-9.eE]+)\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
11715 r"\s*\|dUk\|/\|dUprev\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
11716 r"\s*\|Rk\|/\|Rprev\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
11719 for path
in sorted(glob.glob(pattern)):
11720 block_match = re.search(
r"Block_(\d+)\.log$", path)
11721 if not block_match:
11723 block = int(block_match.group(1))
11724 sources[block] = path
11725 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11727 match = regex.search(raw_line)
11730 step = int(match.group(
"step"))
11731 step_order.append(step)
11732 rows_by_step.setdefault(step, {})[block] = {
11734 "pseudo_iterations": int(match.group(
"pseudo_iter")),
11735 "pseudo_cfl": float(match.group(
"pseudo_cfl")),
11736 "delta_norm": float(match.group(
"delta")),
11737 "delta_rel": float(match.group(
"delta_rel")),
11738 "residual_norm": float(match.group(
"resid")),
11739 "residual_rel": float(match.group(
"resid_rel")),
11741 return rows_by_step, sources, step_order
11746 @brief Parse per-block Poisson convergence logs.
11747 @param[in] log_dir Argument passed to `_parse_poisson_convergence_logs()`.
11748 @return Value returned by `_parse_poisson_convergence_logs()`.
11753 pattern = os.path.join(log_dir,
"Poisson_Solver_Convergence_History_Block_*.log")
11754 regex = re.compile(
11755 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
11756 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
11757 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
11760 for path
in sorted(glob.glob(pattern)):
11761 block_match = re.search(
r"Block_(\d+)\.log$", path)
11762 if not block_match:
11764 block = int(block_match.group(1))
11765 sources[block] = path
11766 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11768 match = regex.search(raw_line)
11771 step = int(match.group(
"step"))
11772 step_order.append(step)
11773 rows_by_step.setdefault(step, {})[block] = {
11775 "iterations": int(match.group(
"iter")),
11776 "unpreconditioned_norm": float(match.group(
"unpre")),
11777 "true_norm": float(match.group(
"true")),
11780 return rows_by_step, sources, step_order
11785 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
11786 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
11787 @return Value returned by `_parse_profiling_timestep_csv()`.
11792 if not os.path.isfile(filepath):
11793 return rows_by_step, step_order
11795 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
11796 reader = csv.DictReader(f)
11801 if step != active_step:
11803 step_order.append(step)
11804 rows_by_step[step] = []
11805 rows_by_step.setdefault(step, []).append(
11807 "function": row.get(
"function"),
11812 return rows_by_step, step_order
11817 @brief Parse Runtime_Memory.log into latest rows by step and final status.
11818 @param[in] filepath Runtime memory log path.
11819 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
11824 latest_sample_row =
None
11825 max_process_change_mb =
None
11826 if not os.path.isfile(filepath):
11827 return rows_by_step, step_order, {
"available":
False}
11829 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11831 line = raw_line.strip()
11832 if not line
or line.startswith(
"#")
or line.startswith(
"Step"):
11834 parts = line.split()
11848 "reason": parts[7],
11850 if row[
"process_change_mb_max"]
is not None:
11851 max_process_change_mb = (
11852 row[
"process_change_mb_max"]
11853 if max_process_change_mb
is None
11854 else max(max_process_change_mb, row[
"process_change_mb_max"])
11856 if row[
"event"]
in {
"Step",
"Post"}:
11857 rows_by_step[step] = row
11858 step_order.append(step)
11859 latest_sample_row = row
11860 elif row[
"event"]
in {
"Shutdown",
"Final"}:
11864 "available": bool(rows_by_step
or final_row),
11865 "source": filepath,
11866 "final_event": final_row.get(
"event")
if final_row
else None,
11867 "final_reason": final_row.get(
"reason")
if final_row
else None,
11868 "max_process_change_mb": max_process_change_mb,
11869 "latest_sample_row": latest_sample_row,
11870 "final_row": final_row,
11872 return rows_by_step, step_order, meta
11877 @brief Parse solution_convergence.log into latest rows by step plus observed order.
11879 The log format uses pipe-delimited aligned columns. The first line of the
11880 file is a banner (starts with '=') containing the mode tag; the second line
11881 is the column header; the third line is a separator (starts with '-').
11882 Subsequent lines are one data row per timestep.
11884 @param[in] filepath Path to solution_convergence.log.
11885 @return Mapping of step number to a dict of column values.
11889 if not os.path.isfile(filepath):
11890 return rows_by_step, step_order
11895 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11897 line = raw_line.strip()
11900 if line.startswith(
"="):
11901 m = re.search(
r"\[mode:\s*([\w_]+)", line)
11905 if line.startswith(
"-"):
11907 if col_names
is None:
11908 col_names = [p.strip()
for p
in raw_line.split(
"|")]
11910 parts = [p.strip()
for p
in raw_line.split(
"|")]
11911 if len(parts) < 4
or col_names
is None:
11916 step_order.append(step)
11917 row = {
"mode": mode}
11918 for name, val
in zip(col_names, parts):
11923 if float_val
is not None and (
"." in val
or "e" in val.lower()):
11924 row[name] = float_val
11925 elif int_val
is not None:
11926 row[name] = int_val
11929 rows_by_step[step] = row
11930 return rows_by_step, step_order
11935 @brief Return plausible solver stream logs for local and Slurm runs.
11936 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
11937 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
11938 @return Value returned by `_find_solver_stream_log_candidates()`.
11941 os.path.join(run_dir,
"scheduler",
"*_solver.log"),
11942 os.path.join(run_dir,
"scheduler",
"solver_*.out"),
11943 os.path.join(log_dir,
"*_solver.log"),
11947 for pattern
in patterns:
11948 for path
in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=
True):
11949 if path
not in seen:
11957 @brief Parse sampled particle snapshots from a solver stream log.
11958 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
11959 @return Value returned by `_parse_particle_snapshot_file()`.
11962 if not os.path.isfile(filepath):
11965 with open(filepath,
"r", encoding=
"utf-8", errors=
"replace")
as f:
11966 lines = f.readlines()
11969 while idx < len(lines):
11970 match = re.search(
r"Particle states at step\s+(\d+):", lines[idx])
11974 step = int(match.group(1))
11977 while idx < len(lines):
11978 stripped = lines[idx].strip()
11979 if re.search(
r"Particle states at step\s+\d+:", lines[idx]):
11981 if stripped.startswith(
"|"):
11982 parts = [part.strip()
for part
in stripped.split(
"|")[1:-1]]
11983 if len(parts) >= 6
and parts[0] !=
"Rank":
11991 "velocity": velocity,
11993 "sample_speed": math.sqrt(sum(component * component
for component
in velocity))
if len(velocity) == 3
else None,
11996 elif rows
and (
not stripped
or stripped.startswith(
"Progress:")):
12000 snapshots[step] = rows
12002 snapshots.setdefault(step, [])
12008 @brief Return the nearest earlier snapshot step when available.
12009 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
12010 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
12011 @return Value returned by `_find_previous_snapshot_step()`.
12013 earlier_steps = [candidate
for candidate
in snapshot_steps
if candidate < step]
12014 if not earlier_steps:
12016 return max(earlier_steps)
12021 @brief Compute sampled deltas between two particle snapshot samples.
12022 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
12023 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
12024 @return Value returned by `_compute_particle_snapshot_delta()`.
12027 previous_by_pid = {
12028 row.get(
"pid"): row
12029 for row
in previous_rows
12030 if row.get(
"pid")
is not None
12033 row.get(
"pid"): row
12034 for row
in current_rows
12035 if row.get(
"pid")
is not None
12037 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
12038 if not matched_pids:
12039 return {
"available":
False}
12042 rank_migrations = 0
12045 for pid
in matched_pids:
12046 current_row = current_by_pid[pid]
12047 previous_row = previous_by_pid[pid]
12048 current_pos = current_row.get(
"position")
or []
12049 previous_pos = previous_row.get(
"position")
or []
12050 if len(current_pos) == len(previous_pos)
and current_pos:
12051 displacements.append(
12054 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
12055 for idx
in range(len(current_pos))
12059 current_speed = current_row.get(
"sample_speed")
12060 previous_speed = previous_row.get(
"sample_speed")
12061 if current_speed
is not None and previous_speed
is not None:
12062 speed_changes.append(float(current_speed) - float(previous_speed))
12063 if current_row.get(
"rank")
is not None and previous_row.get(
"rank")
is not None:
12064 if current_row[
"rank"] != previous_row[
"rank"]:
12065 rank_migrations += 1
12066 if current_row.get(
"cell")
and previous_row.get(
"cell"):
12067 if current_row[
"cell"] != previous_row[
"cell"]:
12072 "matched_pids": len(matched_pids),
12073 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
12074 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
12075 "rank_migrations": rank_migrations,
12076 "cell_changes": cell_changes,
12079 payload[
"mean_displacement"] = float(np.mean(displacements))
12080 payload[
"max_displacement"] = float(np.max(displacements))
12082 payload[
"mean_speed_change"] = float(np.mean(speed_changes))
12083 payload[
"max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
12090 rows:
"list[dict]",
12092 particle_console_output_freq,
12093 particle_log_interval,
12094 previous_step:
"int | None" =
None,
12095 previous_rows:
"list[dict] | None" =
None,
12098 @brief Build sampled diagnostics for one particle console snapshot.
12099 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
12100 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
12101 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
12102 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
12103 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
12104 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
12105 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
12106 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
12107 @return Value returned by `_build_particle_snapshot_summary()`.
12115 "sampled_rows": len(rows),
12116 "preview_rows": rows[:preview_rows],
12118 "particle_console_output_frequency": particle_console_output_freq,
12119 "particle_log_interval": particle_log_interval,
12126 duplicate_pid_count = 0
12127 duplicate_cell_count = 0
12130 zero_weight_count = 0
12131 negative_weight_count = 0
12132 unique_pid_count = 0
12136 position_components = [[], [], []]
12137 weight_components = {}
12141 pid = row.get(
"pid")
12142 if pid
is not None:
12143 if pid
in seen_pids:
12144 duplicate_pid_count += 1
12147 rank = row.get(
"rank")
12148 if rank
is not None:
12149 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
12151 cell = row.get(
"cell")
or []
12154 cell_counter[key] = cell_counter.get(key, 0) + 1
12156 position = row.get(
"position")
or []
12157 for idx, value
in enumerate(position[:3]):
12158 if not np.isfinite(value):
12159 if np.isnan(value):
12164 position_components[idx].append(float(value))
12166 velocity = row.get(
"velocity")
or []
12167 if any(
not np.isfinite(value)
for value
in velocity):
12168 for value
in velocity:
12169 if not np.isfinite(value):
12170 if np.isnan(value):
12174 speed = row.get(
"sample_speed")
12175 if speed
is not None and np.isfinite(speed):
12176 speeds.append(float(speed))
12178 weights = row.get(
"weights")
or []
12179 for idx, value
in enumerate(weights):
12180 if not np.isfinite(value):
12181 if np.isnan(value):
12186 numeric = float(value)
12187 weight_components.setdefault(idx, []).append(numeric)
12188 if abs(numeric) <= 1.0e-15:
12189 zero_weight_count += 1
12191 negative_weight_count += 1
12193 unique_pid_count = len(seen_pids)
12194 duplicate_cell_count = sum(1
for count
in cell_counter.values()
if count > 1)
12196 payload[
"sampled_distribution"] = {
12197 "unique_cells": len(cell_counter),
12198 "duplicate_cells": duplicate_cell_count,
12199 "rank_counts": rank_counts,
12200 "unique_pids": unique_pid_count,
12202 payload[
"checks"] = {
12203 "duplicate_pid_count": duplicate_pid_count,
12204 "nan_count": nan_count,
12205 "inf_count": inf_count,
12206 "zero_weight_count": zero_weight_count,
12207 "negative_weight_count": negative_weight_count,
12211 payload[
"speed"] = {
12212 "min": float(np.min(speeds)),
12213 "mean": float(np.mean(speeds)),
12214 "max": float(np.max(speeds)),
12215 "std": float(np.std(speeds)),
12216 "stagnant_count": sum(1
for speed
in speeds
if abs(speed) < 1.0e-6),
12219 fastest_rows = sorted(
12220 [row
for row
in rows
if row.get(
"sample_speed")
is not None],
12221 key=
lambda row: row[
"sample_speed"],
12224 payload[
"top_speeds"] = [
12226 "pid": row.get(
"pid"),
12227 "rank": row.get(
"rank"),
12228 "speed": row.get(
"sample_speed"),
12229 "cell": row.get(
"cell"),
12231 for row
in fastest_rows
12234 if any(position_components):
12235 axes = [
"x",
"y",
"z"]
12236 payload[
"position_bounds"] = {}
12238 for idx, axis
in enumerate(axes):
12239 values = position_components[idx]
12241 payload[
"position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
12242 centroid.append(float(np.mean(values)))
12244 centroid.append(
None)
12245 payload[
"position_centroid"] = centroid
12247 if weight_components:
12248 payload[
"weights"] = {}
12249 for idx, values
in sorted(weight_components.items()):
12250 payload[
"weights"][f
"component_{idx}"] = {
12251 "min": float(np.min(values)),
12252 "max": float(np.max(values)),
12255 delta_summary = {
"available":
False}
12256 if previous_step
is not None and previous_rows:
12258 if delta_summary.get(
"available"):
12259 delta_summary[
"previous_step"] = previous_step
12260 payload[
"delta_from_previous_snapshot"] = delta_summary
12269 particle_console_output_freq,
12270 particle_log_interval,
12273 @brief Locate and summarize a particle console snapshot for one step.
12274 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
12275 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
12276 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
12277 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
12278 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
12279 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
12280 @return Value returned by `_find_particle_snapshot_for_step()`.
12285 rows = snapshots.get(step)
12288 if best
is None or len(rows) > len(best[
"rows"]):
12289 best = {
"source": path,
"rows": rows,
"snapshots": snapshots}
12292 return {
"available":
False}
12295 previous_rows = best[
"snapshots"].get(previous_step, [])
if previous_step
is not None else None
12301 particle_console_output_freq=particle_console_output_freq,
12302 particle_log_interval=particle_log_interval,
12303 previous_step=previous_step,
12304 previous_rows=previous_rows,
12316 convergence_rows=None,
12318 selection_mode: str =
"latest",
12321 @brief Select a step to summarize from available metric artifacts.
12322 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
12323 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
12324 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
12325 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
12326 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
12327 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
12328 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
12329 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
12330 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
12331 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
12332 @return Value returned by `_resolve_summary_step()`.
12334 if memory_rows
is None:
12336 if convergence_rows
is None:
12337 convergence_rows = {}
12338 if step_orders
is None:
12340 available_steps = (
12341 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
12342 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
12344 if not available_steps:
12347 if requested_step
is not None:
12348 return requested_step, sorted(available_steps)
12350 if selection_mode ==
"max_step":
12351 return max(available_steps), sorted(available_steps)
12353 for order
in step_orders:
12355 return order[-1], sorted(available_steps)
12356 return max(available_steps), sorted(available_steps)
12361 @brief Format optional numeric values for summary text output.
12362 @param[in] value Argument passed to `_format_summary_float()`.
12363 @param[in] spec Argument passed to `_format_summary_float()`.
12364 @param[in] missing Argument passed to `_format_summary_float()`.
12365 @return Value returned by `_format_summary_float()`.
12369 return format(value, spec)
12374 @brief Return the newest modification time among one or more summary sources.
12375 @param[in] paths Path string, iterable of paths, or mapping of paths.
12376 @return Newest modification time, or -1.0 when no source exists.
12378 if isinstance(paths, dict):
12379 paths = paths.values()
12380 elif isinstance(paths, str):
12383 for path
in paths
or []:
12384 if path
and os.path.isfile(path):
12385 newest = max(newest, os.path.getmtime(path))
12391 @brief Order observed step sequences by the recency of their source files.
12392 @param[in] sources Pairs of observed steps and filesystem source path(s).
12393 @return Step-order lists sorted so active append sources are considered first.
12396 for priority, (order, paths)
in enumerate(sources):
12399 ranked.sort(key=
lambda item: (-item[0], item[1]))
12400 return [order
for _, _, order
in ranked]
12405 @brief Build a read-only run-step summary from existing PICurv artifacts.
12406 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
12407 @param[in] step Argument passed to `build_run_summary_payload()`.
12408 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
12409 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
12410 @return Value returned by `build_run_summary_payload()`.
12413 log_dir = context[
"log_dir"]
12414 continuity_path = os.path.join(log_dir,
"Continuity_Metrics.log")
12415 particle_metrics_path = os.path.join(log_dir,
"Particle_Metrics.log")
12422 profiling_rows = {}
12423 profiling_order = []
12424 profiling_path = os.path.join(log_dir, context[
"profiling_cfg"].get(
"timestep_file",
"Profiling_Timestep_Summary.csv"))
12425 if context[
"profiling_cfg"].get(
"mode") !=
"off":
12429 memory_log_file = diagnostics_cfg[
"runtime_memory_log"].get(
"file",
"Runtime_Memory.log")
12430 memory_path = os.path.join(log_dir, memory_log_file)
12433 convergence_log_path = os.path.join(log_dir,
"solution_convergence.log")
12437 (continuity_order, continuity_path),
12438 (particle_order, particle_metrics_path),
12439 (convergence_order, convergence_log_path),
12440 (profiling_order, profiling_path),
12441 (memory_order, memory_path),
12442 (momentum_order, momentum_sources),
12443 (poisson_order, poisson_sources),
12456 step_orders=step_orders,
12457 selection_mode=selection_mode,
12459 if resolved_step
is None:
12461 ERROR_CODE_CFG_FILE_NOT_FOUND,
12464 message=
"No summary-capable run artifacts were found under the run log directory.",
12465 hint=
"Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
12469 if step
is not None and step
not in set(available_steps):
12471 ERROR_CODE_CFG_INVALID_VALUE,
12473 file_path=context[
"run_dir"],
12474 message=f
"Requested step {step} is not present in the available summary artifacts.",
12475 hint=f
"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
12479 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=
lambda row: row[
"block"])
12480 continuity_summary = {
"available": bool(continuity_step_rows),
"blocks": continuity_step_rows}
12481 if continuity_step_rows:
12482 divergence_values = [
12483 abs(row[
"max_divergence"])
12484 for row
in continuity_step_rows
12485 if row[
"max_divergence"]
is not None
12487 continuity_summary[
"max_abs_divergence"] = max(divergence_values)
if divergence_values
else None
12488 continuity_summary[
"net_flux"] = continuity_step_rows[0].get(
"net_flux")
12489 continuity_summary[
"flux_in"] = continuity_step_rows[0].get(
"flux_in")
12490 continuity_summary[
"flux_out"] = continuity_step_rows[0].get(
"flux_out")
12492 momentum_step_rows = [row
for _, row
in sorted(momentum_rows.get(resolved_step, {}).items())]
12493 momentum_summary = {
"available": bool(momentum_step_rows),
"blocks": momentum_step_rows}
12495 poisson_step_rows = [row
for _, row
in sorted(poisson_rows.get(resolved_step, {}).items())]
12496 poisson_summary = {
"available": bool(poisson_step_rows),
"blocks": poisson_step_rows}
12498 particle_summary = {
"available": resolved_step
in particle_rows}
12499 if resolved_step
in particle_rows:
12500 particle_summary.update(particle_rows[resolved_step])
12502 profiling_summary = {
"available": resolved_step
in profiling_rows}
12503 if resolved_step
in profiling_rows:
12504 functions = sorted(
12505 profiling_rows[resolved_step],
12506 key=
lambda row: (row.get(
"step_time_s")
or 0.0),
12509 profiling_summary[
"functions"] = functions
12510 profiling_summary[
"total_logged_step_time_s"] = sum(
12511 row.get(
"step_time_s")
or 0.0
for row
in functions
12514 memory_summary = {
"available": resolved_step
in memory_rows}
12515 if resolved_step
in memory_rows:
12516 memory_summary.update(memory_rows[resolved_step])
12517 memory_summary[
"source"] = memory_path
12518 memory_summary[
"max_process_change_mb"] = memory_meta.get(
"max_process_change_mb")
12519 memory_summary[
"final_event"] = memory_meta.get(
"final_event")
12520 memory_summary[
"final_reason"] = memory_meta.get(
"final_reason")
12521 memory_summary[
"selected_step"] = resolved_step
12522 memory_summary[
"step_match"] =
True
12523 elif memory_meta.get(
"available"):
12524 latest_sample_row = memory_meta.get(
"latest_sample_row")
12525 if latest_sample_row:
12526 memory_summary.update(latest_sample_row)
12527 memory_summary.update(memory_meta)
12528 memory_summary[
"selected_step"] = resolved_step
12529 memory_summary[
"step_match"] =
False
12531 snapshot_summary = {
"available":
False}
12532 if context[
"particle_console_output_freq"]
and context[
"particle_console_output_freq"] > 0:
12534 context[
"run_dir"],
12537 preview_rows=max(1, snapshot_rows),
12538 particle_console_output_freq=context[
"particle_console_output_freq"],
12539 particle_log_interval=context[
"particle_log_interval"],
12543 "profiling_timestep_mode": context[
"profiling_cfg"].get(
"mode"),
12544 "profiling_timestep_file": context[
"profiling_cfg"].get(
"timestep_file"),
12545 "particle_console_output_frequency": context[
"particle_console_output_freq"],
12546 "particle_log_interval": context[
"particle_log_interval"],
12550 "run_id": context[
"manifest"].get(
"run_id", os.path.basename(context[
"run_dir"])),
12551 "run_dir": context[
"run_dir"],
12552 "step": resolved_step,
12553 "selected_via":
"explicit" if step
is not None else (
"max_step" if selection_mode ==
"max_step" else "latest_available"),
12554 "available_steps": available_steps,
12555 "launch_mode": context[
"manifest"].get(
"launch_mode"),
12556 "created_at": context[
"manifest"].get(
"created_at"),
12557 "monitor": monitor_info,
12558 "particles_configured": context[
"particle_count_cfg"],
12560 "continuity_log": continuity_path
if os.path.isfile(continuity_path)
else None,
12561 "particle_metrics_log": particle_metrics_path
if os.path.isfile(particle_metrics_path)
else None,
12562 "momentum_logs": momentum_sources,
12563 "poisson_logs": poisson_sources,
12564 "profiling_timestep_csv": profiling_path
if os.path.isfile(profiling_path)
else None,
12565 "solution_convergence_log": convergence_log_path
if os.path.isfile(convergence_log_path)
else None,
12566 "runtime_memory_log": memory_path
if os.path.isfile(memory_path)
else None,
12568 "continuity": continuity_summary,
12569 "momentum": momentum_summary,
12570 "poisson": poisson_summary,
12571 "particles": particle_summary,
12572 "particle_snapshot": snapshot_summary,
12573 "profiling": profiling_summary,
12574 "memory": memory_summary,
12575 "convergence": convergence_rows.get(resolved_step)
if convergence_rows
else None,
12581 @brief Render a run-step summary in human or JSON form.
12582 @param[in] payload Argument passed to `render_run_summary()`.
12583 @param[in] output_format Argument passed to `render_run_summary()`.
12585 if output_format ==
"json":
12586 print(json.dumps(payload, indent=2, sort_keys=
True))
12589 print(
"\n" +
"=" * 60)
12590 print(
" RUN STEP SUMMARY")
12592 print(f
" Run ID : {payload.get('run_id')}")
12593 print(f
" Run directory : {os.path.relpath(payload.get('run_dir'))}")
12594 print(f
" Step : {payload.get('step')} ({payload.get('selected_via')})")
12595 if payload.get(
"launch_mode"):
12596 print(f
" Launch mode : {payload.get('launch_mode')}")
12597 if payload.get(
"created_at"):
12598 print(f
" Created at : {payload.get('created_at')}")
12600 continuity = payload.get(
"continuity", {})
12601 print(
"\n Continuity:")
12602 if continuity.get(
"available"):
12603 if continuity.get(
"max_abs_divergence")
is not None:
12604 print(f
" max |div| : {continuity['max_abs_divergence']:.6e}")
12605 if continuity.get(
"net_flux")
is not None:
12606 print(f
" net flux : {continuity['net_flux']:.6e}")
12607 for row
in continuity.get(
"blocks", []):
12610 f
"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
12611 f
"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
12614 print(
" unavailable")
12616 momentum = payload.get(
"momentum", {})
12617 print(
"\n Momentum:")
12618 if momentum.get(
"available"):
12619 for row
in momentum.get(
"blocks", []):
12622 f
"block {row['block']}: pseudo_iter={row['pseudo_iterations']} "
12623 f
"cfl={_format_summary_float(row.get('pseudo_cfl'), '.4f')} "
12624 f
"resid={_format_summary_float(row.get('residual_norm'))} "
12625 f
"rel={_format_summary_float(row.get('residual_rel'))}"
12628 print(
" unavailable")
12630 poisson = payload.get(
"poisson", {})
12631 print(
"\n Poisson:")
12632 if poisson.get(
"available"):
12633 for row
in poisson.get(
"blocks", []):
12636 f
"block {row['block']}: iter={row['iterations']} "
12637 f
"true={_format_summary_float(row.get('true_norm'))} "
12638 f
"rel={_format_summary_float(row.get('relative_norm'))}"
12641 print(
" unavailable")
12643 convergence = payload.get(
"convergence")
12644 print(
"\n Solution Convergence:")
12645 if convergence
is not None:
12646 mode = convergence.get(
"mode",
"unknown")
12647 ref = convergence.get(
"ref")
12648 print(f
" mode : {mode} (ref={'yes' if ref else 'no'})")
12649 if mode
in (
"steady_deterministic",
"transient"):
12650 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
12651 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
12652 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
12653 elif mode ==
"periodic_deterministic":
12654 ph = convergence.get(
"ph")
12655 per = convergence.get(
"per")
12656 print(f
" phase : {ph}/{per}")
12657 print(f
" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
12658 print(f
" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
12659 print(f
" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
12660 elif mode ==
"statistical_steady":
12661 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'))}")
12662 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'))}")
12664 print(
" unavailable")
12666 particles = payload.get(
"particles", {})
12667 print(
"\n Particles:")
12668 if particles.get(
"available"):
12669 loss_summary = f
"lost={particles.get('lost_particles')}"
12670 if particles.get(
"lost_particles_cumulative")
is not None:
12672 f
"lost(step/total)={particles.get('lost_particles')}/"
12673 f
"{particles.get('lost_particles_cumulative')}"
12677 f
"total={particles.get('total_particles')} {loss_summary} "
12678 f
"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
12679 f
"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
12682 print(
" unavailable")
12684 memory = payload.get(
"memory", {})
12685 print(
"\n Runtime Memory:")
12686 if memory.get(
"available"):
12687 if memory.get(
"source"):
12688 print(f
" source : {os.path.relpath(memory.get('source'))}")
12689 if memory.get(
"step")
is not None and not memory.get(
"step_match",
True):
12690 print(f
" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
12691 if memory.get(
"event"):
12692 print(f
" event : {memory.get('event')} reason={memory.get('reason', '-')}")
12693 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")
12694 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")
12695 print(f
" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
12696 if memory.get(
"final_reason"):
12697 print(f
" final reason : {memory.get('final_reason')}")
12699 print(
" unavailable")
12701 snapshot = payload.get(
"particle_snapshot", {})
12702 if snapshot.get(
"available"):
12703 print(
"\n Particle Snapshot (sampled):")
12704 print(f
" source : {os.path.relpath(snapshot.get('source'))}")
12705 cadence = snapshot.get(
"cadence", {})
12708 f
"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
12709 f
"row interval {cadence.get('particle_log_interval', 'n/a')}"
12711 print(f
" sampled rows : {snapshot.get('sampled_rows')}")
12712 speed = snapshot.get(
"speed", {})
12716 f
"sampled speeds: min={_format_summary_float(speed.get('min'))} "
12717 f
"mean={_format_summary_float(speed.get('mean'))} "
12718 f
"max={_format_summary_float(speed.get('max'))} "
12719 f
"std={_format_summary_float(speed.get('std'))} "
12720 f
"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
12722 bounds = snapshot.get(
"position_bounds", {})
12723 centroid = snapshot.get(
"position_centroid")
12726 for axis
in (
"x",
"y",
"z"):
12728 bound_parts.append(
12729 f
"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
12731 print(f
" sampled bounds: {' '.join(bound_parts)}")
12735 f
"sampled center: ({_format_summary_float(centroid[0])}, "
12736 f
"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
12738 distribution = snapshot.get(
"sampled_distribution", {})
12742 f
"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
12743 f
"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
12744 f
"unique_pids={distribution.get('unique_pids', 'n/a')} "
12745 f
"ranks={distribution.get('rank_counts', {})}"
12747 weights = snapshot.get(
"weights", {})
12750 for component, summary
in sorted(weights.items()):
12751 weight_parts.append(
12752 f
"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
12754 print(f
" sampled weights: {' '.join(weight_parts)}")
12755 checks = snapshot.get(
"checks", {})
12759 f
"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
12760 f
"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
12761 f
"zero_weight={checks.get('zero_weight_count', 0)} "
12762 f
"negative_weight={checks.get('negative_weight_count', 0)}"
12764 top_speeds = snapshot.get(
"top_speeds", [])
12766 summary =
", ".join(
12767 f
"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
12768 for row
in top_speeds
12770 print(f
" top speeds : {summary}")
12771 delta_summary = snapshot.get(
"delta_from_previous_snapshot", {})
12772 if delta_summary.get(
"available"):
12775 f
"vs prev snap : step={delta_summary.get('previous_step')} "
12776 f
"matched_pids={delta_summary.get('matched_pids')} "
12777 f
"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
12778 f
"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
12779 f
"rank_moves={delta_summary.get('rank_migrations')} "
12780 f
"cell_changes={delta_summary.get('cell_changes')} "
12781 f
"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
12783 print(
" preview rows :")
12784 for row
in snapshot.get(
"preview_rows", []):
12787 f
"pid={row.get('pid')} rank={row.get('rank')} "
12788 f
"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
12791 profiling = payload.get(
"profiling", {})
12792 print(
"\n Profiling:")
12793 if profiling.get(
"available"):
12794 print(f
" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
12795 for row
in profiling.get(
"functions", [])[:5]:
12798 f
"{row.get('function')}: calls={row.get('calls')} "
12799 f
"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
12802 print(
" unavailable")
12806_CONFIG_SUMMARY_WIDTH = 78
12811 @brief Format one configuration-summary value for compact text output.
12812 @param[in] value Value to format.
12813 @return Compact human-readable value.
12817 if isinstance(value, bool):
12818 return "enabled" if value
else "disabled"
12819 if isinstance(value, float):
12820 return f
"{value:.6g}"
12821 if isinstance(value, (list, tuple)):
12823 if isinstance(value, dict):
12826 return ", ".join(f
"{key}={_summary_display_value(item)}" for key, item
in value.items())
12832 @brief Print a strong dashboard-style configuration summary header.
12833 @param[in] title Section title.
12834 @param[in] subtitle Optional one-line section subtitle.
12836 print(
"\n" +
"=" * _CONFIG_SUMMARY_WIDTH)
12837 print(f
"{title:^78}")
12839 print(f
"{subtitle:^78}")
12840 print(
"=" * _CONFIG_SUMMARY_WIDTH)
12845 @brief Print an aligned configuration-summary field group.
12846 @param[in] title Group title.
12847 @param[in] rows Sequence of `(label, value)` pairs.
12849 visible_rows = [(label, value)
for label, value
in rows
if value
is not None]
12850 if not visible_rows:
12852 print(f
"\n {title}")
12853 print(f
" {'-' * (len(title) + 1)}")
12854 for label, value
in visible_rows:
12855 print(f
" {label:<32} {_summary_display_value(value)}")
12860 @brief Flatten nested summary mappings into readable dotted field rows.
12861 @param[in] mapping Mapping to flatten.
12862 @param[in] prefix Optional parent-field prefix.
12863 @return Sequence of `(field, value)` pairs.
12866 for key, value
in mapping.items():
12867 label = f
"{prefix}.{key}" if prefix
else str(key)
12868 if isinstance(value, dict)
and value:
12871 rows.append((label, value))
12877 @brief Render run metadata as a compact dashboard.
12878 @param[in] summary Curated run overview mapping.
12884 (
"Run directory", os.path.relpath(summary.get(
"run_dir"))
if summary.get(
"run_dir")
else None),
12885 (
"Created", summary.get(
"created_at")),
12886 (
"Launch mode", summary.get(
"launch_mode")),
12887 (
"Git commit", summary.get(
"git_commit")),
12893 (
"Solver MPI processes", summary.get(
"solver_num_procs")),
12894 (
"Post MPI processes", summary.get(
"post_num_procs")),
12895 (
"Stages requested", summary.get(
"stages_requested")),
12896 (
"Stages ready/completed", summary.get(
"stages_completed_or_submitted")),
12903 @brief Render the case summary as a glanceable simulation dashboard.
12904 @param[in] summary Curated case configuration mapping.
12906 run = summary.get(
"run_control", {})
12907 props = summary.get(
"properties", {})
12908 grid = summary.get(
"grid", {})
12909 domain = summary.get(
"domain", {})
12910 physics = summary.get(
"physics", {})
12912 f
"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
12913 f
"Re={_summary_display_value(props.get('reynolds_number'))}"
12919 (
"Step range", f
"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
12920 (
"Physical timestep", run.get(
"dt_physical")),
12921 (
"Nondimensional timestep", run.get(
"dt_nondimensional")),
12922 (
"Physical duration", run.get(
"duration_physical")),
12923 (
"Initial conditions", props.get(
"initial_conditions")),
12927 "Fluid And Scaling",
12929 (
"Reynolds number", props.get(
"reynolds_number")),
12930 (
"Reference length", props.get(
"length_ref")),
12931 (
"Reference velocity", props.get(
"velocity_ref")),
12932 (
"Density", props.get(
"density")),
12933 (
"Viscosity", props.get(
"viscosity")),
12939 (
"Grid mode", grid.get(
"mode")),
12940 (
"Blocks", domain.get(
"blocks")),
12941 (
"Dimensionality", domain.get(
"dimensionality")),
12942 (
"Periodic axes", domain.get(
"periodic")),
12943 (
"MPI grid layout", grid.get(
"processor_layout")),
12944 (
"Grid source", grid.get(
"source_file")),
12947 if grid.get(
"programmatic_settings"):
12952 (
"Particles", physics.get(
"particles")),
12953 (
"FSI", physics.get(
"fsi")),
12954 (
"Turbulence", physics.get(
"turbulence")),
12955 (
"Statistics", physics.get(
"statistics")),
12958 boundary_blocks = summary.get(
"boundary_conditions", [])
12959 if boundary_blocks:
12960 print(
"\n Boundary Conditions")
12961 print(
" --------------------")
12962 print(f
" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
12963 print(f
" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
12964 for block
in boundary_blocks:
12965 for face
in block.get(
"faces", []):
12967 f
" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
12968 f
"{face.get('type', '-'):<12} {face.get('handler', '-')}"
12974 @brief Render the solver summary as a glanceable numerical-method dashboard.
12975 @param[in] summary Curated solver configuration mapping.
12977 momentum = summary.get(
"momentum", {})
12978 poisson = summary.get(
"poisson", {})
12979 operation = summary.get(
"operation_mode", {})
12981 f
"Field: {operation.get('eulerian_field_source', '-')} | "
12982 f
"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
12989 (
"Momentum solver", momentum.get(
"type")),
12990 (
"Central differencing", momentum.get(
"central_diff")),
12991 (
"Poisson method", poisson.get(
"method")),
12992 (
"Interpolation", summary.get(
"interpolation")),
12993 (
"Convergence mode", summary.get(
"solution_convergence", {}).get(
"mode")),
13002 passthrough = summary.get(
"petsc_passthrough", {})
13004 "Advanced PETSc Options",
13005 [(
"Option count", passthrough.get(
"count")), (
"Option names", passthrough.get(
"options"))],
13011 @brief Render the monitor summary as a glanceable observability dashboard.
13012 @param[in] summary Curated monitor configuration mapping.
13014 logging_cfg = summary.get(
"logging", {})
13015 profiling = summary.get(
"profiling", {})
13016 diagnostics = summary.get(
"diagnostics", {})
13017 io_cfg = summary.get(
"io", {})
13018 memory_log = diagnostics.get(
"runtime_memory_log", {})
13020 f
"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
13021 f
"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
13027 (
"Verbosity", logging_cfg.get(
"verbosity")),
13028 (
"Enabled functions", logging_cfg.get(
"enabled_functions")),
13035 (
"Field output", io_cfg.get(
"data_output_frequency")),
13036 (
"Particle snapshots", io_cfg.get(
"particle_console_output_frequency")),
13037 (
"Particle row interval", io_cfg.get(
"particle_log_interval")),
13044 (
"Enabled PETSc diagnostics", diagnostics.get(
"enabled_petsc")),
13045 (
"Runtime memory log", memory_log.get(
"enabled")),
13046 (
"Runtime memory file", memory_log.get(
"file")),
13050 solver_monitoring = summary.get(
"solver_monitoring", {})
13052 "Solver Monitoring",
13054 (
"Enabled flags", solver_monitoring.get(
"enabled_flags")),
13055 (
"All flags", solver_monitoring.get(
"flags")),
13062 @brief Render selected timestep-independent config views and optional health.
13063 @param[in] payload Combined selected summary payload.
13064 @param[in] output_format Output format.
13066 if output_format ==
"json":
13067 json_payload = {key: value
for key, value
in payload.items()
if key !=
"_health_requested"}
13068 print(json.dumps(json_payload, indent=2, sort_keys=
True))
13071 if payload.get(
"run_overview")
is not None:
13074 "case": _render_case_summary_text,
13075 "solver": _render_solver_summary_text,
13076 "monitor": _render_monitor_summary_text,
13078 for key
in (
"case",
"solver",
"monitor"):
13079 if key
in payload.get(
"configuration", {}):
13080 renderers[key](payload[
"configuration"][key])
13081 if payload.get(
"_health_requested"):
13082 health_payload = {key: value
for key, value
in payload.items()
if key
not in {
"run_overview",
"configuration",
"_health_requested"}}
13086_SUMMARY_PLOT_LOG_SCALE_FIELDS = {
13087 "delta_norm",
"delta_rel",
"residual_norm",
"residual_rel",
13088 "unpreconditioned_norm",
"true_norm",
"relative_norm",
13089 "u_abs_l2",
"u_rel_l2",
"p_abs_l2",
"p_rel_l2",
13095 @brief Append one numeric append-ordered record for summarize plotting.
13096 @param[out] records Destination record list.
13097 @param[in] source Qualified source prefix.
13098 @param[in] step Logged timestep.
13099 @param[in] line Human-readable line identity.
13100 @param[in] values Candidate field mapping.
13101 @param[in] source_path Source artifact path.
13102 @param[in] segment Zero-based continuation segment within the source artifact.
13106 for key, value
in values.items()
13107 if isinstance(value, (int, float))
and not isinstance(value, bool)
13109 if step
is not None and numeric:
13115 "source_path": source_path,
13116 "segment": int(segment),
13122 @brief Return whether a log line starts a new continuation segment.
13123 @param[in] line Candidate raw or stripped log line.
13124 @return True for the shared continuation marker syntax.
13126 return bool(re.match(
r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
13131 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
13132 @param[in] context Summary context returned by `_build_summary_context()`.
13133 @return Append-ordered plot record list.
13136 log_dir = context[
"log_dir"]
13138 continuity_path = os.path.join(log_dir,
"Continuity_Metrics.log")
13139 if os.path.isfile(continuity_path):
13141 with open(continuity_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13146 parts = [part.strip()
for part
in raw_line.split(
"|")]
13151 records,
"continuity", step, f
"block {block}",
13159 continuity_path, segment,
13162 particle_path = os.path.join(log_dir,
"Particle_Metrics.log")
13163 if os.path.isfile(particle_path):
13165 with open(particle_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13170 parts = [part.strip()
for part
in raw_line.split(
"|")]
13174 offset = 1
if len(parts) >= 9
else 0
13176 records,
"particles", step,
"particles",
13180 "lost_particles_cumulative":
_parse_int_loose(parts[4])
if offset
else None,
13186 particle_path, segment,
13189 momentum_regex = re.compile(
13190 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\(k\):\s*(?P<pseudo_iter>\d+)\|\s*\|"
13191 r"\s*Pseudo-cfl:\s*(?P<pseudo_cfl>[-+0-9.eE]+)\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
13192 r"\s*\|dUk\|/\|dUprev\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
13193 r"\s*\|Rk\|/\|Rprev\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
13195 for path
in sorted(glob.glob(os.path.join(log_dir,
"Momentum_Solver_Convergence_History_Block_*.log"))):
13196 block_match = re.search(
r"Block_(\d+)\.log$", path)
13197 if not block_match:
13200 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13205 match = momentum_regex.search(raw_line)
13208 records,
"momentum", int(match.group(
"step")), f
"block {block_match.group(1)}",
13210 "pseudo_iterations": int(match.group(
"pseudo_iter")),
13211 "pseudo_cfl": float(match.group(
"pseudo_cfl")),
13212 "delta_norm": float(match.group(
"delta")),
13213 "delta_rel": float(match.group(
"delta_rel")),
13214 "residual_norm": float(match.group(
"resid")),
13215 "residual_rel": float(match.group(
"resid_rel")),
13220 poisson_regex = re.compile(
13221 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
13222 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
13223 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
13225 for path
in sorted(glob.glob(os.path.join(log_dir,
"Poisson_Solver_Convergence_History_Block_*.log"))):
13227 with open(path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13232 match = poisson_regex.search(raw_line)
13235 records,
"poisson", int(match.group(
"step")), f
"block {match.group('block')}",
13237 "iterations": int(match.group(
"iter")),
13238 "unpreconditioned_norm": float(match.group(
"unpre")),
13239 "true_norm": float(match.group(
"true")),
13245 profiling_path = os.path.join(log_dir, context[
"profiling_cfg"].get(
"timestep_file",
"Profiling_Timestep_Summary.csv"))
13246 if os.path.isfile(profiling_path):
13249 with open(profiling_path,
"r", encoding=
"utf-8", errors=
"replace", newline=
"")
as f:
13254 values = next(csv.reader([raw_line]))
13257 if columns
is None:
13260 row = dict(zip(columns, values))
13262 records,
"profiling",
_parse_int_loose(row.get(
"step")), row.get(
"function")
or "unknown",
13264 profiling_path, segment,
13268 memory_path = os.path.join(log_dir, diagnostics[
"runtime_memory_log"].get(
"file",
"Runtime_Memory.log"))
13269 if os.path.isfile(memory_path):
13271 with open(memory_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13276 parts = raw_line.split()
13277 if len(parts) >= 8
and parts[1]
in {
"Step",
"Post"}:
13287 memory_path, segment,
13290 convergence_path = os.path.join(log_dir,
"solution_convergence.log")
13291 if os.path.isfile(convergence_path):
13294 with open(convergence_path,
"r", encoding=
"utf-8", errors=
"replace")
as f:
13296 line = raw_line.strip()
13300 if not line
or line.startswith((
"=",
"-")):
13302 if columns
is None:
13303 columns = [part.strip()
for part
in raw_line.split(
"|")]
13305 parts = [part.strip()
for part
in raw_line.split(
"|")]
13307 values = {name:
_parse_float_loose(value)
for name, value
in zip(columns, parts)
if name
not in {
"step",
"mode",
"ref"}}
13314 @brief Build available qualified-series metadata from plot records.
13315 @param[in] records Append-ordered plot record list.
13316 @return Available series catalog.
13319 for record
in records:
13320 for field
in record[
"values"]:
13321 name = f
"{record['source']}.{field}"
13322 item = catalog.setdefault(name, {
"series": name,
"lines": {},
"source_paths": set(),
"sample_count": 0})
13323 item[
"lines"][record[
"line"]] = item[
"lines"].get(record[
"line"], 0) + 1
13324 item[
"source_paths"].add(record[
"source_path"])
13325 item[
"sample_count"] += 1
13329 "lines": [{
"label": label,
"sample_count": count}
for label, count
in sorted(item[
"lines"].items())],
13330 "source_paths": sorted(item[
"source_paths"]),
13332 for _, item
in sorted(catalog.items())
13338 @brief Build one normalized plot.gen request from collected summarize records.
13339 @param[in] context Summary context returned by `_build_summary_context()`.
13340 @param[in] records Append-ordered plot record list.
13341 @param[in] series Qualified series name.
13342 @param[in] last_n Optional last-N records per plotted line.
13343 @param[in] linear_y Whether to force linear scaling.
13344 @param[in] output_path Optional explicit output path.
13345 @return Versioned normalized plot request.
13347 source, separator, field = series.partition(
".")
13349 raise ValueError(
"plot series must be qualified as '<source>.<field>'")
13350 matching = [record
for record
in records
if record[
"source"] == source
and field
in record[
"values"]]
13352 raise ValueError(f
"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
13353 latest_segments = {}
13354 for record
in matching:
13355 source_path = record[
"source_path"]
13356 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get(
"segment", 0))
13358 record
for record
in matching
13359 if record.get(
"segment", 0) == latest_segments[record[
"source_path"]]
13362 for record
in matching:
13363 grouped.setdefault(record[
"line"], []).append([record[
"step"], record[
"values"][field]])
13364 if last_n
is not None:
13365 grouped = {label: points[-last_n:]
for label, points
in grouped.items()}
13366 all_values = [point[1]
for points
in grouped.values()
for point
in points]
13367 use_log =
not linear_y
and field
in _SUMMARY_PLOT_LOG_SCALE_FIELDS
and all(value > 0
for value
in all_values)
13368 window_token = f
"last-{last_n}" if last_n
is not None else "full"
13369 safe_series = re.sub(
r"[^A-Za-z0-9_.-]+",
"_", series)
13370 fallback = os.path.join(context[
"run_dir"],
"summary",
"plots", f
"{safe_series}_{window_token}.png")
13372 "schema_version": 1,
13373 "plot_type":
"time_history",
13375 "title": f
"{series} time history",
13376 "x_label":
"Timestep",
13378 "y_scale":
"log" if use_log
else "linear",
13379 "window": {
"mode":
"last" if last_n
is not None else "full",
"last": last_n},
13380 "lines": [{
"label": label,
"points": points}
for label, points
in sorted(grouped.items())],
13381 "output_path": os.path.abspath(output_path)
if output_path
else None,
13382 "fallback_output_path": fallback,
13388 @brief Render available summarize plot-series metadata.
13389 @param[in] catalog Available series catalog.
13390 @param[in] output_format Text or JSON output format.
13392 if output_format ==
"json":
13393 print(json.dumps({
"available_series": catalog}, indent=2, sort_keys=
True))
13395 print(
"\nAVAILABLE TIME-HISTORY SERIES")
13397 for item
in catalog:
13398 labels =
", ".join(line[
"label"]
for line
in item[
"lines"])
13399 print(f
" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
13400 print(f
" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
13405 @brief Invoke standalone plot.gen with one normalized request over stdin.
13406 @param[in] request Versioned normalized plot request.
13408 plotgen_path = os.path.join(GENERATORS_PATH,
"plot.gen")
13409 if not os.path.isfile(plotgen_path):
13410 raise ValueError(f
"plot.gen script not found: {plotgen_path}")
13411 result = subprocess.run(
13412 [sys.executable, plotgen_path,
"--input",
"-"],
13413 input=json.dumps(request),
13415 capture_output=
True,
13419 print(result.stdout.rstrip())
13420 if result.returncode != 0:
13421 details = (result.stderr
or result.stdout
or "unknown plotting error").strip()
13422 if result.returncode == 3:
13424 raise ValueError(f
"plot.gen failed with exit code {result.returncode}: {details}")
13429 @brief Build and render a read-only health summary for a run step.
13430 @param[in] args Command-line style argument list supplied to the function.
13432 if args.step
is not None and args.step < 0:
13434 if args.snapshot_rows < 1:
13436 plot_series = getattr(args,
"plot_series",
None)
13437 list_plot_series = bool(getattr(args,
"list_plot_series",
False))
13438 last_n = getattr(args,
"last_n",
None)
13439 plot_output = getattr(args,
"plot_output",
None)
13440 linear_y = bool(getattr(args,
"linear_y",
False))
13441 plot_mode = bool(plot_series
or list_plot_series)
13442 existing_selectors = any(
13444 getattr(args,
"overview",
False),
13445 getattr(args,
"case",
False),
13446 getattr(args,
"solver",
False),
13447 getattr(args,
"monitor",
False),
13448 args.step
is not None,
13449 getattr(args,
"latest",
False),
13450 getattr(args,
"max_step",
False),
13453 if plot_mode
and existing_selectors:
13454 fail_cli_usage(
"Plot discovery and --plot cannot be combined with config or selected-step selectors.")
13455 if not plot_series
and (last_n
is not None or plot_output
or linear_y):
13456 fail_cli_usage(
"--last, --plot-output, and --linear-y require --plot.")
13457 if last_n
is not None and last_n < 1:
13459 if plot_series
and args.output_format ==
"json":
13460 fail_cli_usage(
"--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
13466 if list_plot_series:
13468 raise ValueError(
"No plottable scalar histories were found in the run logs.")
13474 except PlotDependencyError
as exc:
13476 ERROR_CODE_DEPENDENCY_MISSING,
13478 file_path=sys.executable,
13482 except ValueError
as exc:
13484 ERROR_CODE_CFG_INVALID_VALUE,
13486 file_path=context[
"log_dir"],
13491 selected_configs = {
13493 for name
in (
"case",
"solver",
"monitor")
13494 if bool(getattr(args, name,
False))
13496 if getattr(args,
"overview",
False):
13497 selected_configs.update({
"case",
"solver",
"monitor"})
13498 explicit_health = args.step
is not None or bool(getattr(args,
"latest",
False))
or bool(getattr(args,
"max_step",
False))
13499 health_requested = explicit_health
or (
not selected_configs
and not getattr(args,
"overview",
False))
13503 if selected_configs
or getattr(args,
"overview",
False):
13505 if getattr(args,
"overview",
False):
13507 combined[
"configuration"] = {}
13509 "case": _build_case_overview,
13510 "solver": _build_solver_overview,
13511 "monitor": _build_monitor_overview,
13513 for name
in (
"case",
"solver",
"monitor"):
13514 if name
in selected_configs:
13516 combined[
"configuration"][name] = builders[name](context)
13517 except (KeyError, TypeError, ValueError, ZeroDivisionError)
as exc:
13519 ERROR_CODE_CFG_INVALID_VALUE,
13521 file_path=context[
"config_paths"][name],
13522 message=f
"Could not summarize copied {name}.yml: {exc}",
13526 if not health_requested:
13527 combined[
"_health_requested"] =
False
13531 requested_step = args.step
13532 if requested_step
is None and getattr(args,
"latest",
False):
13533 requested_step =
None
13534 selection_mode =
"max_step" if getattr(args,
"max_step",
False)
else "latest"
13537 step=requested_step,
13538 snapshot_rows=args.snapshot_rows,
13539 selection_mode=selection_mode,
13544 combined = {**health_payload, **combined,
"_health_requested":
True}
13550 @brief Resolve a run/study submission target from explicit directory flags.
13551 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
13552 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
13553 @return Value returned by `_resolve_submission_target()`.
13555 has_run_dir = bool(run_dir)
13556 has_study_dir = bool(study_dir)
13557 if has_run_dir == has_study_dir:
13558 fail_cli_usage(
"submit requires exactly one of --run-dir or --study-dir.")
13560 target_kind =
"run" if has_run_dir
else "study"
13561 target_key =
"run_dir" if target_kind ==
"run" else "study_dir"
13562 root_dir = os.path.abspath(run_dir
if has_run_dir
else study_dir)
13563 if not os.path.isdir(root_dir):
13565 ERROR_CODE_CFG_FILE_NOT_FOUND,
13567 file_path=root_dir,
13568 message=f
"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
13572 scheduler_dir = os.path.join(root_dir,
"scheduler")
13573 submission_path = os.path.join(scheduler_dir,
"submission.json")
13575 if not isinstance(submission_meta, dict):
13577 ERROR_CODE_CFG_FILE_NOT_FOUND,
13578 key=
"scheduler.submission",
13579 file_path=submission_path,
13580 message=
"Target directory does not contain scheduler submission metadata.",
13581 hint=
"Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
13585 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
13586 if launch_mode ==
"local" and target_kind !=
"run":
13588 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13589 key=
"scheduler.launch_mode",
13590 file_path=submission_path,
13591 message=
"Local staged submission is supported for run directories only.",
13592 hint=
"Use --run-dir for local staged execution; study submit remains Slurm-only.",
13595 if launch_mode
not in {
"slurm",
"local"}:
13597 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13598 key=
"scheduler.launch_mode",
13599 file_path=submission_path,
13600 message=f
"Target launch_mode={launch_mode or 'unknown'} is not supported.",
13601 hint=
"Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
13605 if target_kind ==
"run":
13607 "solve": os.path.join(scheduler_dir,
"solver.sbatch"),
13608 "post-process": os.path.join(scheduler_dir,
"post.sbatch"),
13610 display_label =
"Run directory"
13611 manifest_path =
None
13614 "solve": os.path.join(scheduler_dir,
"solver_array.sbatch"),
13615 "post-process": os.path.join(scheduler_dir,
"post_array.sbatch"),
13617 display_label =
"Study directory"
13618 manifest_path = os.path.join(root_dir,
"study_manifest.json")
13621 "target_kind": target_kind,
13622 "target_key": target_key,
13623 "root_dir": root_dir,
13624 "scheduler_dir": scheduler_dir,
13625 "submission_path": submission_path,
13626 "submission_meta": submission_meta,
13627 "launch_mode": launch_mode,
13628 "script_map": script_map,
13629 "display_label": display_label,
13630 "manifest_path": manifest_path,
13636 @brief Return stored metadata for one staged submission target.
13637 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
13638 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
13639 @return Value returned by `_get_submission_stage_metadata()`.
13641 submission_meta = target_context[
"submission_meta"]
13642 if target_context[
"target_kind"] ==
"run":
13643 stages = submission_meta.get(
"stages", {})
13644 if not isinstance(stages, dict):
13646 stage_meta = stages.get(stage_name)
13647 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
13649 key =
"solver_array" if stage_name ==
"solve" else "post_array"
13650 stage_meta = submission_meta.get(key)
13651 return copy.deepcopy(stage_meta)
if isinstance(stage_meta, dict)
else {}
13656 @brief Return stage names explicitly recorded in scheduler submission metadata.
13657 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
13658 @return Value returned by `_get_recorded_submission_stages()`.
13660 submission_meta = target_context[
"submission_meta"]
13662 if target_context[
"target_kind"] ==
"run":
13663 stages = submission_meta.get(
"stages", {})
13664 if isinstance(stages, dict):
13665 for stage_name
in [
"solve",
"post-process"]:
13666 if isinstance(stages.get(stage_name), dict):
13667 recorded.append(stage_name)
13670 if isinstance(submission_meta.get(
"solver_array"), dict):
13671 recorded.append(
"solve")
13672 if isinstance(submission_meta.get(
"post_array"), dict):
13673 recorded.append(
"post-process")
13679 @brief Format a human-readable stage list for submit diagnostics.
13680 @param[in] stage_names Argument passed to `_format_stage_list()`.
13681 @return Value returned by `_format_stage_list()`.
13683 return ", ".join(stage_names)
if stage_names
else "none"
13688 @brief Build an actionable hint for requested submit stages missing from metadata.
13689 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
13690 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
13691 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
13692 @return Value returned by `_build_submit_missing_stage_hint()`.
13695 recorded_set = set(recorded_stages)
13696 selected_set = set(selected_stages)
13697 target_flag =
"--run-dir" if target_context[
"target_kind"] ==
"run" else "--study-dir"
13698 target_path = os.path.relpath(target_context[
"root_dir"])
13699 submit_prefix = f
"picurv submit {target_flag} {target_path}"
13700 solve_stage_command = (
13701 "picurv run --solve ... --no-submit"
13702 if target_context[
"target_kind"] ==
"run"
13703 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
13705 post_stage_command = (
13706 "picurv run --post-process --post <post.yml> ... --no-submit"
13707 if target_context[
"target_kind"] ==
"run"
13708 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
13710 solve_post_command = (
13711 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
13712 if target_context[
"target_kind"] ==
"run"
13713 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
13716 if requested_stage ==
"all":
13717 if recorded_set == {
"solve"}:
13719 "--stage all requests solve and post-process, but this target records only solve. "
13720 f
"Use `{submit_prefix} --stage solve`, or re-stage with post-processing enabled "
13721 f
"(`{solve_post_command}`)."
13723 if recorded_set == {
"post-process"}:
13725 "--stage all requests solve and post-process, but this target records only post-process. "
13726 f
"Use `{submit_prefix} --stage post-process`, or re-stage including the solve stage "
13727 f
"(`{solve_stage_command}`)."
13729 missing = [stage
for stage
in selected_stages
if stage
not in recorded_set]
13732 "--stage all requests solve and post-process, but submission metadata records "
13733 f
"{_format_stage_list(recorded_stages)}. Re-stage the missing stage(s): "
13734 f
"{_format_stage_list(missing)}."
13737 if selected_set == {
"solve"}
and "solve" not in recorded_set:
13739 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
13740 f
"Re-stage with `{solve_stage_command}`."
13742 if selected_set == {
"post-process"}
and "post-process" not in recorded_set:
13744 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
13745 f
"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
13748 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
13753 @brief Persist one stage's metadata back into the submission payload.
13754 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
13755 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
13756 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
13758 submission_meta = target_context[
"submission_meta"]
13759 if target_context[
"target_kind"] ==
"run":
13760 stages = submission_meta.get(
"stages")
13761 if not isinstance(stages, dict):
13763 submission_meta[
"stages"] = stages
13764 stages[stage_name] = stage_meta
13767 key =
"solver_array" if stage_name ==
"solve" else "post_array"
13768 submission_meta[key] = stage_meta
13773 @brief Write updated submission metadata back to disk.
13774 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
13776 write_json_file(target_context[
"submission_path"], target_context[
"submission_meta"])
13778 manifest_path = target_context.get(
"manifest_path")
13779 if manifest_path
and os.path.isfile(manifest_path):
13781 if isinstance(manifest_payload, dict):
13782 manifest_payload[
"submission"] = target_context[
"submission_meta"]
13788 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
13789 @param[in] args Command-line style argument list supplied to the function.
13792 run_dir=getattr(args,
"run_dir",
None),
13793 study_dir=getattr(args,
"study_dir",
None),
13795 stage_order = [
"solve",
"post-process"]
13796 requested_stage = args.stage
13797 selected_stages = stage_order
if requested_stage ==
"all" else [requested_stage]
13799 print(f
"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
13800 print(f
"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
13801 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
13803 if target_context.get(
"launch_mode") ==
"local":
13809 solve_existing_job_id = str(solve_existing_meta.get(
"job_id",
"")).strip()
13811 for stage_name
in selected_stages:
13813 script_path = target_context[
"script_map"][stage_name]
13815 if not existing_meta:
13817 ERROR_CODE_CFG_MISSING_KEY,
13818 key=f
"scheduler.{stage_name}.metadata",
13819 file_path=target_context[
"submission_path"],
13820 message=f
"Submission metadata does not record stage '{stage_name}'.",
13821 hint=missing_stage_hint,
13825 if not os.path.isfile(script_path):
13827 ERROR_CODE_CFG_FILE_NOT_FOUND,
13828 key=f
"scheduler.{stage_name}.script",
13829 file_path=script_path,
13830 message=f
"Required {stage_name} sbatch artifact is missing.",
13831 hint=missing_stage_hint,
13835 if existing_meta.get(
"submitted")
and not args.force:
13837 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13838 key=f
"scheduler.{stage_name}.submitted",
13839 file_path=target_context[
"submission_path"],
13840 message=f
"Stage '{stage_name}' is already recorded as submitted.",
13841 hint=
"Use --force to resubmit this stage intentionally.",
13846 if stage_name ==
"post-process":
13847 if "solve" in selected_stages:
13848 dependency =
"__NEW_SOLVE_JOB_ID__"
13850 if not (solve_existing_meta.get(
"submitted")
and solve_existing_job_id):
13852 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13853 key=
"scheduler.post-process.dependency",
13854 file_path=target_context[
"submission_path"],
13855 message=
"Post-process submission requires a recorded solve job id when solve is not being submitted in the same command.",
13856 hint=
"Submit --stage solve or --stage all first, or use --force only after solve metadata exists.",
13859 dependency = solve_existing_job_id
13861 stage_plans.append(
13863 "stage": stage_name,
13864 "script": script_path,
13865 "dependency": dependency,
13866 "existing_meta": existing_meta,
13871 for plan
in stage_plans:
13873 dependency = plan[
"dependency"]
13874 if dependency ==
"__NEW_SOLVE_JOB_ID__":
13875 cmd.append(
"--dependency=afterok:<new solve job id>")
13877 cmd.append(f
"--dependency=afterok:{dependency}")
13878 cmd.append(plan[
"script"])
13879 print(f
"[DRY-RUN] Would run: {' '.join(cmd)}")
13880 print(
"[INFO] Dry-run only. No jobs were submitted.")
13883 latest_solve_job_id =
None
13884 for plan
in stage_plans:
13885 dependency = plan[
"dependency"]
13886 if dependency ==
"__NEW_SOLVE_JOB_ID__":
13887 dependency = latest_solve_job_id
13889 submit_info =
submit_sbatch(plan[
"script"], dependency=dependency)
13890 stage_meta = copy.deepcopy(plan[
"existing_meta"])
13891 stage_meta.update(submit_info)
13892 stage_meta[
"script"] = plan[
"script"]
13893 stage_meta[
"submitted"] =
True
13895 stage_meta[
"dependency"] = f
"afterok:{dependency}"
13897 stage_meta.pop(
"dependency",
None)
13900 print(f
"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
13902 if plan[
"stage"] ==
"solve":
13903 latest_solve_job_id = submit_info[
"job_id"]
13910 @brief Execute previously staged local run commands from scheduler/submission.json.
13911 @param[in] args Command-line style argument list supplied to the function.
13912 @param[in] target_context Resolved submission target context.
13913 @param[in] selected_stages Ordered stage names selected by the user.
13915 if target_context[
"target_kind"] !=
"run":
13917 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13918 key=
"scheduler.launch_mode",
13919 file_path=target_context[
"submission_path"],
13920 message=
"Local staged execution is supported for run directories only.",
13921 hint=
"Use --run-dir for local staged execution.",
13927 solve_already_done = bool(solve_existing_meta.get(
"submitted")
or solve_existing_meta.get(
"executed"))
13929 for stage_name
in selected_stages:
13931 command = existing_meta.get(
"command")
13932 if not isinstance(command, list)
or not command:
13934 hint =
"Re-stage the run with picurv run --no-submit before calling picurv submit."
13938 ERROR_CODE_CFG_MISSING_KEY,
13939 key=f
"scheduler.{stage_name}.command",
13940 file_path=target_context[
"submission_path"],
13941 message=f
"Required local command metadata for stage '{stage_name}' is missing.",
13946 if existing_meta.get(
"submitted")
and not args.force:
13948 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13949 key=f
"scheduler.{stage_name}.submitted",
13950 file_path=target_context[
"submission_path"],
13951 message=f
"Stage '{stage_name}' is already recorded as submitted.",
13952 hint=
"Use --force to execute this stage again intentionally.",
13956 if stage_name ==
"post-process" and "solve" not in selected_stages
and not args.force
and not solve_already_done:
13958 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13959 key=
"scheduler.post-process.dependency",
13960 file_path=target_context[
"submission_path"],
13961 message=
"Post-process local execution requires a recorded completed solve stage when solve is not being executed in the same command.",
13962 hint=
"Submit --stage solve or --stage all first, or use --force after confirming source data exists.",
13966 log_file = existing_meta.get(
"log_file")
13967 if not isinstance(log_file, str)
or not log_file.strip():
13968 log_file = os.path.join(
"scheduler", f
"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
13970 stage_plans.append(
13972 "stage": stage_name,
13973 "command": [str(token)
for token
in command],
13974 "log_file": log_file,
13975 "existing_meta": existing_meta,
13980 for plan
in stage_plans:
13981 print(f
"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
13982 print(f
"[DRY-RUN] Log file : {plan['log_file']}")
13983 print(
"[INFO] Dry-run only. No local commands were executed.")
13987 monitor_path = os.path.join(target_context[
"root_dir"],
"config",
"monitor.yml")
13988 if os.path.isfile(monitor_path):
13991 for plan
in stage_plans:
13992 execute_command(plan[
"command"], target_context[
"root_dir"], plan[
"log_file"], monitor_cfg)
13993 stage_meta = copy.deepcopy(plan[
"existing_meta"])
13994 stage_meta[
"command"] = plan[
"command"]
13996 stage_meta[
"log_file"] = plan[
"log_file"]
13997 stage_meta[
"submitted"] =
True
13998 stage_meta[
"executed"] =
True
13999 stage_meta[
"completed_at"] = datetime.now().isoformat()
14001 print(f
"[SUCCESS] Executed local {plan['stage']} stage.")
14008 @brief Cancel Slurm-submitted jobs for an existing run directory.
14009 @param[in] args Command-line style argument list supplied to the function.
14011 run_dir = os.path.abspath(args.run_dir)
14012 if not os.path.isdir(run_dir):
14014 ERROR_CODE_CFG_FILE_NOT_FOUND,
14017 message=
"Run directory not found.",
14021 submission_path = os.path.join(run_dir,
"scheduler",
"submission.json")
14023 if not isinstance(submission_meta, dict):
14025 ERROR_CODE_CFG_FILE_NOT_FOUND,
14026 key=
"scheduler.submission",
14027 file_path=submission_path,
14028 message=
"Run directory does not contain scheduler submission metadata.",
14029 hint=
"Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
14033 launch_mode = str(submission_meta.get(
"launch_mode",
"")).lower()
14034 if launch_mode !=
"slurm":
14036 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14037 key=
"scheduler.launch_mode",
14038 file_path=submission_path,
14039 message=f
"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
14040 hint=
"picurv cancel currently supports Slurm-submitted runs only.",
14044 stage_order = [
"solve",
"post-process"]
14045 requested_stage = args.stage
14046 selected_stages = stage_order
if requested_stage ==
"all" else [requested_stage]
14047 recorded_stages = submission_meta.get(
"stages", {})
14048 if not isinstance(recorded_stages, dict):
14049 recorded_stages = {}
14053 for stage_name
in selected_stages:
14054 stage_meta = recorded_stages.get(stage_name)
14055 if not isinstance(stage_meta, dict):
14056 skipped.append((stage_name,
"no stage metadata recorded"))
14059 job_id = str(stage_meta.get(
"job_id",
"")).strip()
14060 if not stage_meta.get(
"submitted"):
14061 skipped.append((stage_name,
"job was generated but not submitted"))
14064 skipped.append((stage_name,
"submitted stage is missing a recorded job id"))
14067 job_to_stages.setdefault(job_id, []).append(stage_name)
14069 if not job_to_stages:
14070 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
14071 print(f
"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14072 for stage_name, reason
in skipped:
14073 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
14074 print(
"[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
14077 print(f
"[INFO] Run directory : {os.path.relpath(run_dir)}")
14078 print(f
"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14079 print(f
"[INFO] Requested stages : {', '.join(selected_stages)}")
14082 for stage_name, reason
in skipped:
14083 print(f
"[INFO] Skipping stage '{stage_name}': {reason}")
14085 graceful = bool(getattr(args,
"graceful",
False))
14087 for job_id, stage_names
in job_to_stages.items():
14088 joined_stage_names =
", ".join(stage_names)
14089 use_graceful_signal = graceful
and "solve" in stage_names
14090 scancel_cmd = [
"scancel"]
14091 if use_graceful_signal:
14092 scancel_cmd.append(
"--signal=USR1")
14093 scancel_cmd.append(job_id)
14096 print(f
"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
14099 result = subprocess.run(scancel_cmd, text=
True, capture_output=
True, check=
False)
14100 stderr_text = (result.stderr
or "").strip()
14101 stdout_text = (result.stdout
or "").strip()
14102 if result.returncode == 0:
14103 if use_graceful_signal:
14105 f
"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
14106 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
14109 print(f
"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
14112 detail = stderr_text
or stdout_text
or "unknown scancel failure"
14113 failures.append((job_id, joined_stage_names, detail, result.returncode))
14115 f
"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
14120 print(
"[INFO] Dry-run only. No jobs were canceled.")
14129 @brief Implements the 'init' command.
14130 @details Creates a new case study directory by copying a template.
14131 Runtime binaries are resolved from the project bin/ directory
14132 via PATH; use 'sync-binaries' to pin specific versions locally.
14133 @param[in] args The command-line arguments parsed by argparse.
14139 except ValueError
as exc:
14140 print(f
"[FATAL] {exc}", file=sys.stderr)
14144 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name
if args.dest_name
else args.template_name))
14146 if os.path.exists(dest_path):
14147 print(f
"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
14150 print(f
"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
14152 shutil.copytree(template_path, dest_path)
14153 print(f
"[SUCCESS] Copied template files to: {dest_path}")
14155 copied_runtime_example = os.path.join(dest_path, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
14156 if os.path.isfile(copied_runtime_example):
14157 os.remove(copied_runtime_example)
14161 print(f
"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14162 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14163 print(
" Seeded from repo-local '.picurv-execution.yml'.")
14164 print(
" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
14165 except Exception
as e:
14166 print(f
"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
14171 source_project_root,
14172 template_name=args.template_name,
14175 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
14178 print(f
"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
14179 except Exception
as e:
14180 print(f
"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
14182 cluster_profile_candidates = sorted(
14184 os.path.basename(path)
14185 for pattern
in (
"*cluster*.yml",
"*cluster*.yaml")
14186 for path
in glob.glob(os.path.join(dest_path, pattern))
14189 if cluster_profile_candidates:
14190 print(
"[INFO] Cluster profile sample(s) copied with this case:")
14191 for profile_name
in cluster_profile_candidates:
14192 print(f
" - {profile_name}")
14193 print(
" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
14195 if getattr(args,
"pin_binaries",
False):
14196 print(
"[INFO] Pinning runtime binaries into case directory...")
14199 for dest_file_path
in copied_binaries:
14200 print(f
" - Pinned '{os.path.basename(dest_file_path)}'")
14201 print(
"[SUCCESS] Case directory is ready with pinned binaries.")
14202 print(
" These local copies will be used instead of bin/ originals.")
14203 except ValueError
as exc:
14204 print(f
"[WARNING] {exc}", file=sys.stderr)
14205 print(
" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
14207 print(
"[SUCCESS] Case directory is ready.")
14208 print(
" Runtime binaries (simulator, postprocessor) are resolved from bin/ automatically.")
14209 print(
" To pin specific binary versions, re-run with --pin-binaries or use: picurv sync-binaries")
14210 print(
" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
14215 @brief Refresh case-local executables from the source repository bin directory.
14216 @param[in] args Command-line style argument list supplied to the function.
14220 case_dir_hint=getattr(args,
"case_dir",
None),
14221 source_root_override=getattr(args,
"source_root",
None),
14228 source_project_root,
14229 template_name=context.get(
"template_name"),
14230 existing=context.get(
"metadata"),
14232 except ValueError
as exc:
14233 print(f
"[FATAL] {exc}", file=sys.stderr)
14236 print(f
"[SUCCESS] Refreshed {len(copied)} binaries in: {case_dir}")
14237 for dest_path
in copied:
14238 print(f
" - {os.path.basename(dest_path)}")
14239 print(f
"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14240 if metadata.get(
"last_known_source_git_commit"):
14241 print(f
"[INFO] Source commit recorded: {metadata['last_known_source_git_commit']}")
14246 @brief Refresh template-managed config/docs files in a case directory.
14247 @param[in] args Command-line style argument list supplied to the function.
14251 case_dir_hint=getattr(args,
"case_dir",
None),
14252 source_root_override=getattr(args,
"source_root",
None),
14253 template_name_override=getattr(args,
"template_name",
None),
14257 template_name = context.get(
"template_name")
14259 existing_managed = context.get(
"metadata", {}).get(
"template_managed_files")
14260 if not isinstance(existing_managed, list):
14261 existing_managed =
None
14265 overwrite=getattr(args,
"overwrite",
False),
14266 prune=getattr(args,
"prune",
False),
14267 managed_rel_paths=existing_managed,
14271 source_project_root,
14272 template_name=template_name,
14273 existing=context.get(
"metadata"),
14274 template_managed_files=summary[
"template_managed_files"],
14277 except ValueError
as exc:
14278 print(f
"[FATAL] {exc}", file=sys.stderr)
14281 print(f
"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
14282 print(f
"[INFO] Copied new files : {len(summary['copied'])}")
14283 print(f
"[INFO] Overwritten files : {len(summary['overwritten'])}")
14284 print(f
"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
14285 print(f
"[INFO] Already unchanged : {len(summary['unchanged'])}")
14286 print(f
"[INFO] Pruned stale files : {len(summary['pruned'])}")
14287 if runtime_result[
"created"]:
14288 print(f
"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14289 if runtime_result[
"seed_source"]
and os.path.basename(runtime_result[
"seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14290 print(
"[INFO] Seed source : repo-local .picurv-execution.yml")
14291 if summary.get(
"prune_requested_without_tracking"):
14292 print(
"[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
14293 print(f
"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14298 @brief Refresh source branches in the repository resolved from a case directory.
14299 @param[in] args Command-line style argument list supplied to the function.
14303 case_dir_hint=getattr(args,
"case_dir",
None),
14304 source_root_override=getattr(args,
"source_root",
None),
14307 except ValueError
as exc:
14308 print(f
"[FATAL] {exc}", file=sys.stderr)
14311 rebase =
not getattr(args,
"no_rebase",
False)
14312 remote = getattr(args,
"remote",
None)
14313 branch = getattr(args,
"branch",
None)
14314 current_branch_only = (
14315 getattr(args,
"current_branch_only",
False)
14316 or remote
is not None
14317 or branch
is not None
14320 if not current_branch_only:
14324 command = [
"git",
"pull"]
14326 command.append(
"--rebase")
14328 command.append(remote)
14330 command.append(branch)
14332 command.extend([
"origin", branch])
14338 @brief Implements the 'build' command.
14339 @details Executes the top-level Makefile directly, passing through any
14340 additional arguments to `make`. This allows for building,
14341 cleaning, and other Makefile targets via the orchestrator
14342 without maintaining a separate build wrapper script.
14343 @param[in] args The command-line arguments parsed by argparse.
14346 print(
"\n" +
"="*27 +
" BUILD STAGE " +
"="*27)
14349 case_dir_hint=getattr(args,
"case_dir",
None),
14350 source_root_override=getattr(args,
"source_root",
None),
14353 except ValueError
as exc:
14354 print(f
"[FATAL] {exc}", file=sys.stderr)
14357 makefile_path = os.path.join(source_project_root,
"Makefile")
14359 if not os.path.isfile(makefile_path):
14360 print(f
"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
14361 print(
" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
14364 make_args =
list(args.make_args
or [])
14366 command = [
"make"] + make_args
14368 command = [
"make",
"all"] + make_args
14369 print(
"[INFO] No explicit make target supplied; defaulting to 'all'.")
14370 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.
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.
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.